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..5d9cb9a --- /dev/null +++ b/src/main/java/com/piggygaming/ezmapdl/DirectoryBrowserScreen.java @@ -0,0 +1,206 @@ +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) { + // 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..4d71a02 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,164 @@ 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); + } +}