From 706db5a6c0150ff2b947c980de56e42ac4805aff Mon Sep 17 00:00:00 2001 From: TCGM Date: Sat, 7 Feb 2026 05:40:34 -0800 Subject: [PATCH 1/2] Add directory browser and async world scanning Features: - Native Minecraft directory browser (no Swing/AWT dependencies) - Asynchronous world file scanning with live progress display - Display all valid world files in directory, not just most recent - Scrollable world selection list with mouse wheel support - Smart Downloads folder detection (respects custom locations) - Drive browser with capacity display - Graceful handling of corrupted/invalid zip files Technical: - Added DirectoryBrowserScreen for native directory navigation - Added WorldScanner for background thread scanning - Added async scanning with tick() updates - Optimized to filter .zip files first - Added null checks for corrupted zip files - Made getFileExtension() public - Added comprehensive error handling Fixes: - Fixed HeadlessException crash from JFileChooser - Fixed NullPointerException with corrupted zips - Fixed Drives button not showing drives - Fixed performance issues with large directories --- .gitignore | 7 +- CHANGELOG.md | 58 +++++ README.md | 53 ++++- .../ezmapdl/DirectoryBrowserScreen.java | 208 ++++++++++++++++++ .../com/piggygaming/ezmapdl/FileUtils.java | 159 +++++++++++-- .../ezmapdl/InstallMapsScreen.java | 200 +++++++++++++++-- .../com/piggygaming/ezmapdl/WorldScanner.java | 135 ++++++++++++ 7 files changed, 777 insertions(+), 43 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 src/main/java/com/piggygaming/ezmapdl/DirectoryBrowserScreen.java create mode 100644 src/main/java/com/piggygaming/ezmapdl/WorldScanner.java diff --git a/.gitignore b/.gitignore index b740e72..ba9b3e8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ # Project exclude paths /.gradle/ -/build/ \ No newline at end of file +/build/ + +# VSCode +.vscode/ + +.history/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d756c3e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,58 @@ +# Changelog + +All notable changes to EasyMapDownload will be documented in this file. + +## [Unreleased] + +### Added +- **Asynchronous world scanning**: Scans directories progressively in background thread without blocking UI +- **Live progress display**: Shows "Scanning... X/Y files (Z worlds found)" while scanning +- **World selection list**: Shows all valid world zip files found in directory, not just the most recent +- **Scrollable world list**: Browse through multiple worlds with mouse wheel or navigation buttons +- **World counter**: Displays how many valid worlds were found +- **Native directory browser**: Custom Minecraft-based directory browsing screen (no AWT/Swing) +- **Multiple navigation methods**: Click folders, type/paste paths, or open in Windows Explorer +- **Scroll support**: Mouse wheel scrolling through long directory lists +- **Drive/root browsing**: Easy access to all drives on your system with size display +- **Smart Downloads detection**: Automatically detects user's configured Downloads folder (Windows/Mac/Linux) +- **Current directory display**: Screen shows the currently selected directory path +- **Dynamic file detection**: Screen automatically updates as new worlds are found + +### Changed +- **Scanning now asynchronous**: No longer blocks UI when scanning large directories +- Screen now lists ALL valid world files instead of just the most recent one +- Users can select which world to install from a list +- Title changed to "Select World to Install" to reflect new functionality +- Optimized file scanning to filter .zip files first before validation +- World list updates automatically as scanning progresses +- Replaced JFileChooser (Swing) with native Minecraft GUI to avoid HeadlessException crashes +- Downloads folder detection now checks system configuration instead of hardcoded paths +- Error handling improved for cases when no valid zip file is found in selected directory +- Drive browser shows drive letter and available space + +### Fixed +- **HeadlessException crash**: Solved java.awt.HeadlessException when clicking Browse button +- **Drives button now working**: Fixed logic to properly show all system drives when clicked +- **Custom Downloads folder**: Now respects Windows custom Downloads folder settings +- **NullPointerException with corrupted zips**: Gracefully skips invalid or corrupted zip files instead of crashing +- **Unsupported compression methods**: Handles zip files with unsupported compression (like method 9) without errors +- Files can now be browsed in native Minecraft environment without external dependencies + +### Technical Details +- Added `WorldScanner` class for asynchronous background scanning +- Scanner runs in daemon thread with progress tracking +- Added `tick()` override to update UI as scan progresses +- Added `removed()` override to stop scanner when leaving screen +- Scanner includes small delays every 10 files to avoid overwhelming system +- Added `getAllWorldFiles()` method to return list of all valid world files +- Added `DirectoryBrowserScreen` class for native directory navigation +- Added `showDrives` flag to control drive listing mode +- Added `getDownloadsFolder()` utility method with PowerShell integration for Windows +- Changed `InstallMapsScreen` to display scrollable list of worlds with selection +- Added `worldFiles` list and `selectedFile` tracking +- Added `refreshWorldList()` method to rebuild world button list +- Enhanced drive display to show capacity information +- Made `getFileExtension()` public for use by WorldScanner + +## [1.1.1] - Previous Release +- Initial functionality with hardcoded Downloads folder support diff --git a/README.md b/README.md index 01cd2b2..9a2de09 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,56 @@ # EasyMapDownload -Tired of installing minecraft maps manually? This fabric mod adds a button to the select world screen that allows you to instantly install minecraft worlds from your downloads folder. +Tired of installing minecraft maps manually? This fabric mod adds a button to the select world screen that allows you to instantly install minecraft worlds from any folder on your computer. + +## Features +- **Asynchronous scanning** - Progressively scans directories over time without freezing the game +- **Live progress display** - Shows scan progress and worlds as they're found +- **World selection list** - View and select from all valid world zip files in a directory +- **One-click world installation** - Install Minecraft worlds with a single click +- **Native directory browser** - Browse directories using a Minecraft-native interface (no external windows) +- **Smart Downloads detection** - Automatically finds your configured Downloads folder (even if customized) +- **Efficient scanning** - Filters to .zip files only and gracefully handles thousands of files +- **Drive browser** - View all system drives with available space +- **Mouse wheel scrolling** - Scroll through long lists of worlds +- **Quick navigation** - Navigate with folder buttons, type paths directly, or open in Windows Explorer ### Cannot find a valid zip file? -Make sure you have a zip file of a minecraft world in your downloads folder (C:\users\user\Downloads) +Make sure you have a zip file of a minecraft world in your selected folder. The mod will automatically detect the most recently modified world zip file that contains a `level.dat` file. + +Use the **Browse** button to select a different directory if your world files are not in the Downloads folder. ![Gif of the mod being used to install a world ](https://i.gyazo.com/c6b6950d3dbe656e442bb01d639c7d97.gif) + +## Building and Testing + +### Build the Mod +```bash +# Windows +gradlew.bat build + +# Linux/Mac +./gradlew build +``` + +The built mod jar will be in `build/libs/` + +### Install for Testing +1. Build the mod using the command above +2. Copy `build/libs/[modname]-[version].jar` to your Minecraft `.minecraft/mods` folder +3. Make sure you have Fabric Loader and Fabric API installed +4. Launch Minecraft 1.21 with Fabric +5. Go to the Select World screen to see the new "Install Map" button + +### Development Testing +```bash +# Run Minecraft client directly from development environment +gradlew.bat runClient +``` + +This will launch Minecraft with your mod automatically loaded for testing. + +## Changelog + +### Recent Changes +- **Added directory browsing** - Users can now browse and select any directory to search for world files +- **UI improvements** - Display current directory path on screen +- **Dynamic file detection** - Screen refreshes when browsing to a new directory diff --git a/src/main/java/com/piggygaming/ezmapdl/DirectoryBrowserScreen.java b/src/main/java/com/piggygaming/ezmapdl/DirectoryBrowserScreen.java new file mode 100644 index 0000000..56f1278 --- /dev/null +++ b/src/main/java/com/piggygaming/ezmapdl/DirectoryBrowserScreen.java @@ -0,0 +1,208 @@ +package com.piggygaming.ezmapdl; + +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.gui.widget.TextFieldWidget; +import net.minecraft.screen.ScreenTexts; +import net.minecraft.text.Text; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Custom directory browser screen for Minecraft. + * Allows users to navigate directories and select a folder without using AWT/Swing. + */ +@Environment(EnvType.CLIENT) +public class DirectoryBrowserScreen extends Screen { + + private final Screen parent; + private final DirectorySelectCallback callback; + private String currentPath; + private TextFieldWidget pathField; + private final List directoryButtons = new ArrayList<>(); + private int scrollOffset = 0; + private boolean showDrives = false; + private static final int MAX_VISIBLE_DIRECTORIES = 10; + + @FunctionalInterface + public interface DirectorySelectCallback { + void onDirectorySelected(String path); + } + + public DirectoryBrowserScreen(Screen parent, String initialPath, DirectorySelectCallback callback) { + super(Text.literal("Browse for Directory")); + this.parent = parent; + this.currentPath = initialPath; + this.callback = callback; + } + + @Override + protected void init() { + // Path text field at the top + this.pathField = new TextFieldWidget(this.textRenderer, this.width / 2 - 150, 20, 300, 20, Text.literal("Path")); + this.pathField.setMaxLength(500); + this.pathField.setText(currentPath); + this.addSelectableChild(this.pathField); + + // Navigate to typed path button + this.addDrawableChild(ButtonWidget.builder(Text.literal("Go"), (button) -> { + String path = this.pathField.getText(); + File dir = new File(path); + if (dir.exists() && dir.isDirectory()) { + this.currentPath = dir.getAbsolutePath(); + this.pathField.setText(this.currentPath); + showDrives = false; + refreshDirectoryList(); + } + }).dimensions(this.width / 2 + 155, 20, 40, 20).build()); + + // Parent directory button + this.addDrawableChild(ButtonWidget.builder(Text.literal("⬆ Parent"), (button) -> { + File current = new File(currentPath); + File parent = current.getParentFile(); + if (parent != null && parent.exists()) { + this.currentPath = parent.getAbsolutePath(); + this.pathField.setText(this.currentPath); + showDrives = false; + refreshDirectoryList(); + } + }).dimensions(this.width / 2 - 150, 45, 80, 20).build()); + + // List drives/roots button + this.addDrawableChild(ButtonWidget.builder(Text.literal("Drives"), (button) -> { + scrollOffset = 0; + showDrives = true; + this.pathField.setText("[Drives]"); + refreshDirectoryList(); + }).dimensions(this.width / 2 - 65, 45, 60, 20).build()); + + // Confirm button + this.addDrawableChild(ButtonWidget.builder(Text.literal("Select This Folder"), (button) -> { + callback.onDirectorySelected(currentPath); + this.client.setScreen(parent); + }).dimensions(this.width / 2 - 100, this.height - 30, 100, 20).build()); + + // Cancel button + this.addDrawableChild(ButtonWidget.builder(ScreenTexts.CANCEL, (button) -> { + this.client.setScreen(parent); + }).dimensions(this.width / 2 + 5, this.height - 30, 95, 20).build()); + + // Open in Explorer button (Windows only) + if (System.getProperty("os.name").toLowerCase().contains("win")) { + this.addDrawableChild(ButtonWidget.builder(Text.literal("Open in Explorer"), (button) -> { + try { + Runtime.getRuntime().exec("explorer.exe \"" + currentPath + "\""); + } catch (Exception e) { + e.printStackTrace(); + } + }).dimensions(this.width / 2 + 10, 45, 140, 20).build()); + } + + refreshDirectoryList(); + } + + private void refreshDirectoryList() { + // Clear existing directory buttons + directoryButtons.forEach(this::remove); + directoryButtons.clear(); + + File current = new File(currentPath); + File[] files; + + if (showDrives) { + // Show drives/roots + files = File.listRoots(); + } else { + files = current.listFiles(File::isDirectory); + } + + if (files == null) { + files = new File[0]; + } + + // Sort directories + Arrays.sort(files); + + int yPos = 75; + int buttonIndex = 0; + + for (int i = scrollOffset; i < files.length && buttonIndex < MAX_VISIBLE_DIRECTORIES; i++) { + File dir = files[i]; + final String dirPath = dir.getAbsolutePath(); + + String displayName = showDrives ? (dir.getAbsolutePath() + (dir.getTotalSpace() > 0 ? " (" + dir.getTotalSpace() / 1073741824 + " GB)" : "")) : ("📁 " + dir.getName()); + ButtonWidget button = ButtonWidget.builder( + Text.literal(displayName), + (btn) -> { + this.currentPath = dirPath; + this.pathField.setText(this.currentPath); + scrollOffset = 0; + showDrives = false; + refreshDirectoryList(); + } + ).dimensions(this.width / 2 - 150, yPos, 300, 20).build(); + + this.addDrawableChild(button); + directoryButtons.add(button); + + yPos += 22; + buttonIndex++; + } + + // Scroll buttons if needed + if (files.length > MAX_VISIBLE_DIRECTORIES) { + if (scrollOffset > 0) { + this.addDrawableChild(ButtonWidget.builder(Text.literal("▲ Previous"), (button) -> { + scrollOffset = Math.max(0, scrollOffset - MAX_VISIBLE_DIRECTORIES); + refreshDirectoryList(); + }).dimensions(this.width / 2 - 150, yPos, 145, 20).build()); + } + + if (scrollOffset + MAX_VISIBLE_DIRECTORIES < files.length) { + this.addDrawableChild(ButtonWidget.builder(Text.literal("▼ Next"), (button) -> { + scrollOffset += MAX_VISIBLE_DIRECTORIES; + refreshDirectoryList(); + }).dimensions(this.width / 2 + 5, yPos, 145, 20).build()); + } + } + } + + @Override + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + super.renderBackground(context, mouseX, mouseY, delta); + + // Draw path field + this.pathField.render(context, mouseX, mouseY, delta); + + super.render(context, mouseX, mouseY, delta); + + // Title + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 5, 16777215); + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double horizontalAmount, double verticalAmount) { + // Scroll through directories with mouse wheel + File current = new File(currentPath); + File[] files = current.listFiles(File::isDirectory); + + if (files != null && files.length > MAX_VISIBLE_DIRECTORIES) { + if (verticalAmount > 0) { + scrollOffset = Math.max(0, scrollOffset - 1); + refreshDirectoryList(); + } else if (verticalAmount < 0) { + scrollOffset = Math.min(files.length - MAX_VISIBLE_DIRECTORIES, scrollOffset + 1); + refreshDirectoryList(); + } + } + + return super.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount); + } +} diff --git a/src/main/java/com/piggygaming/ezmapdl/FileUtils.java b/src/main/java/com/piggygaming/ezmapdl/FileUtils.java index 6c63bb8..199113d 100644 --- a/src/main/java/com/piggygaming/ezmapdl/FileUtils.java +++ b/src/main/java/com/piggygaming/ezmapdl/FileUtils.java @@ -4,6 +4,10 @@ import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.util.Arrays; +import java.util.Comparator; import java.util.List; import java.util.stream.Collectors; import java.util.zip.ZipEntry; @@ -12,7 +16,94 @@ public class FileUtils { - private static String getFileExtension(File file) { + /** + * Gets the user's actual Downloads folder, checking system settings. + * Falls back to default locations if the configured path cannot be determined. + * + * @return The path to the Downloads folder + */ + public static String getDownloadsFolder() { + String os = System.getProperty("os.name").toLowerCase(); + + if (os.contains("win")) { + // Try Windows Shell folders via powershell for custom Downloads location + try { + Process process = Runtime.getRuntime().exec(new String[]{ + "powershell.exe", + "-Command", + "[Environment]::GetFolderPath('MyDocuments')".replace("MyDocuments", "UserProfile") + " + '\\Downloads'" + }); + + // Try getting from registry + process = Runtime.getRuntime().exec(new String[]{ + "powershell.exe", + "-Command", + "(New-Object -ComObject Shell.Application).NameSpace('shell:Downloads').Self.Path" + }); + + BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); + String path = reader.readLine(); + reader.close(); + process.waitFor(); + + if (path != null && !path.trim().isEmpty() && new File(path).exists()) { + return path; + } + } catch (Exception e) { + // Fall through to defaults + } + + // Try common Windows locations + String userProfile = System.getenv("USERPROFILE"); + if (userProfile != null) { + // Check Downloads folder in user profile + File downloads = new File(userProfile, "Downloads"); + if (downloads.exists() && downloads.isDirectory()) { + return downloads.getAbsolutePath(); + } + } + } else if (os.contains("mac")) { + // macOS + String home = System.getProperty("user.home"); + File downloads = new File(home, "Downloads"); + if (downloads.exists() && downloads.isDirectory()) { + return downloads.getAbsolutePath(); + } + } else { + // Linux and other Unix-like systems + String home = System.getProperty("user.home"); + + // Try XDG user dirs first + File xdgConfig = new File(home, ".config/user-dirs.dirs"); + if (xdgConfig.exists()) { + try (BufferedReader reader = new BufferedReader(new java.io.FileReader(xdgConfig))) { + String line; + while ((line = reader.readLine()) != null) { + if (line.startsWith("XDG_DOWNLOAD_DIR=")) { + String path = line.substring(17).replace("\"", "").replace("$HOME", home); + File downloads = new File(path); + if (downloads.exists() && downloads.isDirectory()) { + return downloads.getAbsolutePath(); + } + } + } + } catch (Exception e) { + // Fall through to defaults + } + } + + // Default Linux Downloads + File downloads = new File(home, "Downloads"); + if (downloads.exists() && downloads.isDirectory()) { + return downloads.getAbsolutePath(); + } + } + + // Final fallback + return System.getProperty("user.home") + File.separator + "Downloads"; + } + + public static String getFileExtension(File file) { String name = file.getName(); int lastIndexOf = name.lastIndexOf("."); if (lastIndexOf == -1) { @@ -66,34 +157,50 @@ public static void unzipFile(String fileZip, File destDir) throws IOException { zis.close(); } - public static File getLastModified(String directoryFilePath) throws IOException { + /** + * Gets all valid Minecraft world zip files from a directory. + * @param directoryFilePath The directory to search + * @return List of all valid world zip files, sorted by modification date (newest first) + */ + public static List getAllWorldFiles(String directoryFilePath) throws IOException { File directory = new File(directoryFilePath); File[] files = directory.listFiles(File::isFile); - long lastModifiedTime = Long.MIN_VALUE; - File chosenFile = null; - - if (files != null) - { - for (File file : files) - { - if (file.lastModified() > lastModifiedTime) - { - if (getFileExtension(file).equals(".zip")) { - if (zipfileContains(file, "level.dat")) { - chosenFile = file; - lastModifiedTime = file.lastModified(); - } - } - } - } + + if (files == null || files.length == 0) { + return List.of(); } - if (chosenFile != null && chosenFile.exists()) { - return chosenFile; - } else { - return null; + + // Filter to only .zip files first to reduce processing + File[] zipFiles = Arrays.stream(files) + .filter(f -> getFileExtension(f).equals(".zip")) + .toArray(File[]::new); + + if (zipFiles.length == 0) { + return List.of(); } + + // Sort by modification date (newest first) + Arrays.sort(zipFiles, Comparator.comparingLong(File::lastModified).reversed()); + + // Collect all valid world files + return Arrays.stream(zipFiles) + .filter(file -> { + try { + return zipfileContains(file, "level.dat"); + } catch (Exception e) { + // Skip corrupted or invalid zip files + EasyMapDownload.LOGGER.debug("Skipping invalid zip file: " + file.getName()); + return false; + } + }) + .collect(Collectors.toList()); } + public static File getLastModified(String directoryFilePath) throws IOException { + List worldFiles = getAllWorldFiles(directoryFilePath); + return worldFiles.isEmpty() ? null : worldFiles.get(0); + } + public static boolean fileNotInRootDir(File zip, String targetFile) { return zipfileContains(zip, "/" + targetFile); } @@ -107,13 +214,17 @@ public static List listContents(File file){ return fileContent; } catch (IOException ioException) { - System.out.println("Error opening zip file" + ioException); + // Silently skip corrupted or invalid zip files + // System.out.println("Error opening zip file: " + file.getName() + " - " + ioException.getMessage()); } return null; } public static boolean zipfileContains(File zipfile, String targetFile) { List list = listContents(zipfile); + if (list == null) { + return false; // Invalid or corrupted zip file + } for (String file : list) { if (file.contains(targetFile)) { return true; diff --git a/src/main/java/com/piggygaming/ezmapdl/InstallMapsScreen.java b/src/main/java/com/piggygaming/ezmapdl/InstallMapsScreen.java index 46830fe..ab461e0 100644 --- a/src/main/java/com/piggygaming/ezmapdl/InstallMapsScreen.java +++ b/src/main/java/com/piggygaming/ezmapdl/InstallMapsScreen.java @@ -13,6 +13,8 @@ import java.io.File; import java.io.IOException; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.TimeUnit; import static com.piggygaming.ezmapdl.FileUtils.*; @@ -23,13 +25,23 @@ public class InstallMapsScreen extends Screen { private final Screen parent; private final MinecraftClient client; private final File savesDirectory; - private File lastModified; + private List worldFiles; + private File selectedFile; + /** The currently selected directory to search for map files. Defaults to Downloads folder. */ + private String selectedDirectory; + private final List worldButtons = new ArrayList<>(); + private int scrollOffset = 0; + private static final int MAX_VISIBLE_WORLDS = 8; + private WorldScanner worldScanner; + private int lastWorldCount = 0; public InstallMapsScreen(Screen parent) throws IOException { - super(Text.literal("Is this file correct?")); + super(Text.literal("Select World to Install")); this.parent = parent; this.client = MinecraftClient.getInstance(); this.savesDirectory = new File(this.client.runDirectory.getPath() + File.separator + "saves"); + this.selectedDirectory = getDownloadsFolder(); + this.worldFiles = new ArrayList<>(); } private void errorScreen(String errorMSG) { @@ -44,20 +56,30 @@ private void errorScreen(Exception exception) { @Override protected void init() { - try { - this.lastModified = getLastModified(System.getProperty("user.home") + File.separator + "Downloads"); + try {worldFiles = getAllWorldFiles(selectedDirectory); + if (selectedFile == null && !worldFiles.isEmpty()) { + selectedFile = worldFiles.get(0); + } } catch (Exception e) { errorScreen(e); } - if (this.lastModified == null) { - errorScreen("Cannot find a valid zip file"); - } - - this.addDrawableChild(ButtonWidget.builder(Text.literal("Confirm"), (button) -> { - File newFile = new File(savesDirectory.getPath() + File.separator + lastModified.getName()); + // Browse button + this.addDrawableChild(ButtonWidget.builder(Text.literal("Browse"), (button) -> { + browseDirectory(); + }).dimensions(this.width / 2 - 150, 20, 100, 20).build()); + + // Install button + this.addDrawableChild(ButtonWidget.builder(Text.literal("Install Selected"), (button) -> { + // Check if file exists when confirming + if (this.selectedFile == null) { + errorScreen("No world file selected. Please browse to a folder with world zip files."); + return; + } + + File newFile = new File(savesDirectory.getPath() + File.separator + selectedFile.getName()); this.client.setScreen(new LoadingScreen(this.parent)); - if (lastModified.renameTo(newFile)) { + if (selectedFile.renameTo(newFile)) { try { if (fileNotInRootDir(newFile, "level.dat")) { unzipThread thread = new unzipThread(newFile.getPath(), savesDirectory, this.client); @@ -72,19 +94,165 @@ protected void init() { } } - }).dimensions(this.width / 2 - 105, this.height /2 + 40, 100, 20).build()); + }).dimensions(this.width / 2 - 100, this.height - 30, 95, 20).build()); + + // Cancel button this.addDrawableChild(ButtonWidget.builder(ScreenTexts.CANCEL, (button) -> { this.client.setScreen(this.parent); - }).dimensions(this.width / 2 + 5, this.height /2 + 40, 100, 20).build()); + }).dimensions(this.width / 2 + 5, this.height - 30, 95, 20).build()); + + refreshWorldList(); + } + + private void startScanning() { + if (worldScanner != null && worldScanner.isScanning()) { + worldScanner.stopScanning(); + } + + worldScanner = new WorldScanner(selectedDirectory); + worldScanner.start(); + worldFiles = new ArrayList<>(); + lastWorldCount = 0; + } + + @Override + public void tick() { + super.tick(); + + // Update world list from scanner + if (worldScanner != null) { + List scannedFiles = worldScanner.getFoundWorldFiles(); + if (scannedFiles.size() > lastWorldCount) { + worldFiles = scannedFiles; + lastWorldCount = scannedFiles.size(); + + // Auto-select first world if none selected + if (selectedFile == null && !worldFiles.isEmpty()) { + selectedFile = worldFiles.get(0); + } + + // Refresh the display + refreshWorldList(); + } + } + } + + @Override + public void removed() { + super.removed(); + // Stop scanning when leaving the screen + if (worldScanner != null) { + worldScanner.stopScanning(); + } + } + + private void refreshWorldList() { + // Clear existing world buttons + worldButtons.forEach(this::remove); + worldButtons.clear(); + + if (worldFiles.isEmpty()) { + return; + } + + int yPos = 50; + int buttonIndex = 0; + + for (int i = scrollOffset; i < worldFiles.size() && buttonIndex < MAX_VISIBLE_WORLDS; i++) { + File file = worldFiles.get(i); + final File currentFile = file; + boolean isSelected = file.equals(selectedFile); + + String displayName = (isSelected ? "► " : "") + file.getName(); + + ButtonWidget button = ButtonWidget.builder( + Text.literal(displayName), + (btn) -> { + this.selectedFile = currentFile; + refreshWorldList(); + } + ).dimensions(this.width / 2 - 150, yPos, 300, 20).build(); + + this.addDrawableChild(button); + worldButtons.add(button); + + yPos += 22; + buttonIndex++; + } + + // Scroll buttons if needed + int scrollButtonY = yPos + 5; + if (worldFiles.size() > MAX_VISIBLE_WORLDS) { + if (scrollOffset > 0) { + this.addDrawableChild(ButtonWidget.builder(Text.literal("▲ Previous"), (button) -> { + scrollOffset = Math.max(0, scrollOffset - MAX_VISIBLE_WORLDS); + refreshWorldList(); + }).dimensions(this.width / 2 - 150, scrollButtonY, 145, 20).build()); + } + + if (scrollOffset + MAX_VISIBLE_WORLDS < worldFiles.size()) { + this.addDrawableChild(ButtonWidget.builder(Text.literal("▼ Next"), (button) -> { + scrollOffset += MAX_VISIBLE_WORLDS; + refreshWorldList(); + }).dimensions(this.width / 2 + 5, scrollButtonY, 145, 20).build()); + } + } + } + + /** + * Opens a Minecraft-native directory browser to allow the user to select a directory to search for map files. + * When a directory is selected, the screen refreshes to show all valid Minecraft world zip files in that directory. + */ + private void browseDirectory() { + this.client.setScreen(new DirectoryBrowserScreen(this, selectedDirectory, (newPath) -> { + this.selectedDirectory = newPath; + + // Start new scan for the new directory + this.selectedFile = null; + scrollOffset = 0; + startScanning(); + + // Rebuild the screen + this.clearChildren(); + this.init(); + })); } @Override public void render(DrawContext context, int mouseX, int mouseY, float delta) { super.renderBackground(context, mouseX, mouseY, delta); super.render(context, mouseX, mouseY, delta); - context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, this.height / 2 - 40, 16777215); - context.drawCenteredTextWithShadow(this.textRenderer, Text.literal(lastModified.getName()), this.width / 2, this.height / 2, 16777215); - + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 5, 16777215); + + // Display the current directory + String dirDisplay = "Directory: " + selectedDirectory; + context.drawCenteredTextWithShadow(this.textRenderer, Text.literal(dirDisplay), this.width / 2, this.height - 45, 11184810); + + // Display count and scanning status + if (worldScanner != null && worldScanner.isScanning()) { + String scanText = "Scanning... " + worldScanner.getFilesScanned() + "/" + worldScanner.getTotalFiles() + " files (" + worldFiles.size() + " worlds found)"; + context.drawCenteredTextWithShadow(this.textRenderer, Text.literal(scanText), this.width / 2, 35, 16777045); + } else if (worldFiles.isEmpty()) { + context.drawCenteredTextWithShadow(this.textRenderer, Text.literal("No valid world zip files found - click Browse"), this.width / 2, this.height / 2 - 20, 16733525); + } else { + String countText = "Found " + worldFiles.size() + " world" + (worldFiles.size() == 1 ? "" : "s"); + context.drawCenteredTextWithShadow(this.textRenderer, Text.literal(countText), this.width / 2, 35, 11184810); + } + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double horizontalAmount, double verticalAmount) { + // Scroll through worlds with mouse wheel + if (worldFiles.size() > MAX_VISIBLE_WORLDS) { + if (verticalAmount > 0) { + scrollOffset = Math.max(0, scrollOffset - 1); + refreshWorldList(); + } else if (verticalAmount < 0) { + scrollOffset = Math.min(worldFiles.size() - MAX_VISIBLE_WORLDS, scrollOffset + 1); + refreshWorldList(); + } + } + return super.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount); } } diff --git a/src/main/java/com/piggygaming/ezmapdl/WorldScanner.java b/src/main/java/com/piggygaming/ezmapdl/WorldScanner.java new file mode 100644 index 0000000..979bfda --- /dev/null +++ b/src/main/java/com/piggygaming/ezmapdl/WorldScanner.java @@ -0,0 +1,135 @@ +package com.piggygaming.ezmapdl; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; + +/** + * Asynchronous world file scanner that progressively scans a directory + * for valid Minecraft world zip files without blocking the main thread. + */ +public class WorldScanner extends Thread { + + private final String directoryPath; + private final List foundWorldFiles; + private volatile boolean isScanning; + private volatile boolean shouldStop; + private int filesScanned; + private int totalFiles; + + public WorldScanner(String directoryPath) { + this.directoryPath = directoryPath; + this.foundWorldFiles = new ArrayList<>(); + this.isScanning = false; + this.shouldStop = false; + this.filesScanned = 0; + this.totalFiles = 0; + this.setDaemon(true); + this.setName("WorldScanner-Thread"); + } + + @Override + public void run() { + isScanning = true; + + try { + File directory = new File(directoryPath); + File[] files = directory.listFiles(File::isFile); + + if (files == null || files.length == 0) { + return; + } + + // Filter to only .zip files first + File[] zipFiles = Arrays.stream(files) + .filter(f -> FileUtils.getFileExtension(f).equals(".zip")) + .toArray(File[]::new); + + totalFiles = zipFiles.length; + + if (zipFiles.length == 0) { + return; + } + + // Sort by modification date (newest first) + Arrays.sort(zipFiles, Comparator.comparingLong(File::lastModified).reversed()); + + // Scan through all zip files progressively + for (File file : zipFiles) { + if (shouldStop) { + break; + } + + filesScanned++; + + try { + if (FileUtils.zipfileContains(file, "level.dat")) { + synchronized (foundWorldFiles) { + foundWorldFiles.add(file); + } + } + } catch (Exception e) { + // Skip corrupted or invalid zip files + EasyMapDownload.LOGGER.debug("Skipping invalid zip file: " + file.getName()); + } + + // Small delay to avoid overwhelming the system + if (filesScanned % 10 == 0) { + Thread.sleep(10); + } + } + } catch (Exception e) { + EasyMapDownload.LOGGER.error("Error during world scanning", e); + } finally { + isScanning = false; + } + } + + /** + * Stops the scanning process. + */ + public void stopScanning() { + shouldStop = true; + } + + /** + * Gets a copy of the currently found world files. + * Thread-safe. + */ + public List getFoundWorldFiles() { + synchronized (foundWorldFiles) { + return new ArrayList<>(foundWorldFiles); + } + } + + /** + * Returns true if the scanner is currently scanning. + */ + public boolean isScanning() { + return isScanning; + } + + /** + * Returns the number of files scanned so far. + */ + public int getFilesScanned() { + return filesScanned; + } + + /** + * Returns the total number of files to scan. + */ + public int getTotalFiles() { + return totalFiles; + } + + /** + * Returns the scanning progress as a percentage (0-100). + */ + public int getProgress() { + if (totalFiles == 0) return 0; + return (int) ((filesScanned / (float) totalFiles) * 100); + } +} From 55e22211451014e4937073a2ade12d66509854b6 Mon Sep 17 00:00:00 2001 From: TCGM Date: Sat, 7 Feb 2026 05:50:44 -0800 Subject: [PATCH 2/2] Fix blur rendering crash in Minecraft 1.21 Fixed IllegalStateException Can only blur once per frame crash by removing duplicate renderBackground calls. The super.render method already handles background rendering, so explicit renderBackground calls were causing the blur effect to be applied twice. Tested and working on Minecraft 1.21.8 --- .../java/com/piggygaming/ezmapdl/DirectoryBrowserScreen.java | 2 -- src/main/java/com/piggygaming/ezmapdl/InstallMapsScreen.java | 1 - 2 files changed, 3 deletions(-) diff --git a/src/main/java/com/piggygaming/ezmapdl/DirectoryBrowserScreen.java b/src/main/java/com/piggygaming/ezmapdl/DirectoryBrowserScreen.java index 56f1278..5d9cb9a 100644 --- a/src/main/java/com/piggygaming/ezmapdl/DirectoryBrowserScreen.java +++ b/src/main/java/com/piggygaming/ezmapdl/DirectoryBrowserScreen.java @@ -176,8 +176,6 @@ private void refreshDirectoryList() { @Override public void render(DrawContext context, int mouseX, int mouseY, float delta) { - super.renderBackground(context, mouseX, mouseY, delta); - // Draw path field this.pathField.render(context, mouseX, mouseY, delta); diff --git a/src/main/java/com/piggygaming/ezmapdl/InstallMapsScreen.java b/src/main/java/com/piggygaming/ezmapdl/InstallMapsScreen.java index ab461e0..4d71a02 100644 --- a/src/main/java/com/piggygaming/ezmapdl/InstallMapsScreen.java +++ b/src/main/java/com/piggygaming/ezmapdl/InstallMapsScreen.java @@ -220,7 +220,6 @@ private void browseDirectory() { @Override public void render(DrawContext context, int mouseX, int mouseY, float delta) { - super.renderBackground(context, mouseX, mouseY, delta); super.render(context, mouseX, mouseY, delta); context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 5, 16777215);