Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
# Project exclude paths
/.gradle/
/build/
/build/

# VSCode
.vscode/

.history/
58 changes: 58 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
53 changes: 51 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
206 changes: 206 additions & 0 deletions src/main/java/com/piggygaming/ezmapdl/DirectoryBrowserScreen.java
Original file line number Diff line number Diff line change
@@ -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<ButtonWidget> 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);
}
}
Loading