Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

### Added

- A World Backups icon sits at the far right of the square icons on the title
screen and on the pause screen. On the title screen it opens the list of
backed-up worlds; in a world it opens that world's backups.
- The backup browser can select several backups at once. Ctrl or Cmd click
toggles a row, Shift click extends the selection, and "Select all" picks
every backup that matches the filter. One confirmation deletes all of them,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import dev.ishaanko.worldarchive.runtime.WorldArchiveRuntime;
import dev.ishaanko.worldarchive.settings.ClientSettingsAccess;
import dev.ishaanko.worldarchive.ui.EditWorldBackupIntegration;
import dev.ishaanko.worldarchive.ui.IconRowBackupIntegration;
import dev.ishaanko.worldarchive.ui.SelectWorldBackupIntegration;
import net.fabricmc.api.ClientModInitializer;
import org.slf4j.Logger;
Expand All @@ -17,6 +18,7 @@ public void onInitializeClient() {
WorldArchiveRuntime runtime = WorldArchiveRuntime.initialize();
SelectWorldBackupIntegration.register(() -> runtime);
EditWorldBackupIntegration.register(() -> runtime);
IconRowBackupIntegration.register(() -> runtime, runtime::openBrowser);
LOGGER.info("{} initialized.", WorldArchiveMetadata.MOD_NAME);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,11 @@ void playRestoredWorld(Screen returnTo, RestoreBackupResult result) {
transitionToRestoredWorld(returnTo, result, true);
}

void openBrowser() {
/** Opens the live world's browser; false when no world is resolved or the runtime is not ready. */
boolean openBrowser() {
BackupWorldContext world = runtime.currentLiveWorld();
if (world == null || runtime.unavailable()) {
return;
return false;
}
Minecraft minecraft = runtime.services().minecraft();
minecraft.execute(() -> {
Expand All @@ -86,6 +87,7 @@ void openBrowser() {
runtime));
}
});
return true;
}

boolean sourceDirectoryAvailable(BackupWorldContext world) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -467,8 +467,9 @@ public void playRestoredWorld(Screen returnTo, RestoreBackupResult result) {
clientFacade.playRestoredWorld(returnTo, result);
}

public void openBrowser() {
navigation.openBrowser();
/** Opens the live world's backup browser; false when there is no resolved live world yet. */
public boolean openBrowser() {
return navigation.openBrowser();
}

private RuntimeState buildState(WorldArchiveConfig config) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package dev.ishaanko.worldarchive.ui;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.BooleanSupplier;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents;
import net.fabricmc.fabric.api.client.screen.v1.Screens;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.components.AbstractWidget;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.FriendsButton;
import net.minecraft.client.gui.screens.PauseScreen;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.TitleScreen;

/**
* Adds the WorldArchive shortcut at the far right of the square-icon row on the title screen
* and the pause screen. On the title screen it opens the world list; in a world it opens
* that world's backups, or the world list while the live world is still being resolved.
*/
public final class IconRowBackupIntegration {
private static final int GAP = 4;

private static final AtomicBoolean REGISTERED = new AtomicBoolean();

private static volatile Supplier<? extends BackupClientFacade> facadeSupplier;

private static volatile BooleanSupplier openLiveWorldBackups;

private IconRowBackupIntegration() {
}

/**
* Registers the global Fabric screen hook. Repeated calls update the actions.
*
* @param facade supplies the facade the world list needs
* @param openLiveWorld opens the browser for the loaded world and reports whether it did
*/
public static void register(
Supplier<? extends BackupClientFacade> facade,
BooleanSupplier openLiveWorld) {
facadeSupplier = Objects.requireNonNull(facade, "facade");
openLiveWorldBackups = Objects.requireNonNull(openLiveWorld, "openLiveWorld");
if (REGISTERED.compareAndSet(false, true)) {
ScreenEvents.AFTER_INIT.register(IconRowBackupIntegration::afterInit);
}
}

private static void afterInit(Minecraft minecraft, Screen screen, int width, int height) {
Button.OnPress action;
if (screen instanceof TitleScreen) {
action = ignored -> minecraft.setScreenAndShow(
new BackupWorldsScreen(screen, currentFacade()));
} else if (screen instanceof PauseScreen && minecraft.hasSingleplayerServer()) {
action = ignored -> {
if (!currentLiveWorldAction().getAsBoolean()) {
minecraft.setScreenAndShow(new BackupWorldsScreen(screen, currentFacade()));
}
};
} else {
return;
}
List<Button> iconRow = iconRow(Screens.getWidgets(screen));
if (iconRow.isEmpty()) {
// No icon row to join; stay out rather than guess a spot.
return;
}
int size = Math.max(WorldArchiveIconButton.SIZE, iconRow.getFirst().getHeight());
Button backups = WorldArchiveIconButton.create(0, iconRow.getFirst().getY(), size, action);
List<Button> row = new ArrayList<>(iconRow);
row.add(backups);
recenter(row, rowCenter(iconRow), iconRow.getFirst().getY(), width);
Screens.getWidgets(screen).add(backups);
}

/**
* Picks the square-icon row holding the Friends button, or the widest square-icon row
* without it. Any square button counts, so icons from other mods stay part of the row
* and the recentered layout cannot overlap them.
*/
private static List<Button> iconRow(List<AbstractWidget> widgets) {
Map<Integer, List<Button>> rows = widgets.stream()
.filter(Button.class::isInstance)
.map(Button.class::cast)
.filter(IconRowBackupIntegration::isSquareIcon)
.collect(Collectors.groupingBy(Button::getY));
return rows.values().stream()
.max(Comparator
.<List<Button>>comparingInt(row -> row.stream()
.anyMatch(FriendsButton.class::isInstance) ? 1 : 0)
.thenComparingInt(List::size)
.thenComparingInt(row -> row.getFirst().getY()))
.map(row -> row.stream()
.sorted(Comparator.comparingInt(Button::getX))
.toList())
.orElse(List.of());
}

private static boolean isSquareIcon(Button button) {
return button.getWidth() == button.getHeight()
&& button.getWidth() >= 16
&& button.getWidth() <= 32;
}

private static int rowCenter(List<Button> iconRow) {
int left = iconRow.getFirst().getX();
Button last = iconRow.getLast();
return (left + last.getX() + last.getWidth()) / 2;
}

/** Lays the row out again around its old center so the new icon does not push off screen. */
private static void recenter(List<Button> row, int centerX, int y, int screenWidth) {
int totalWidth = row.stream().mapToInt(Button::getWidth).sum() + GAP * (row.size() - 1);
int x = Math.clamp(
centerX - totalWidth / 2,
GAP,
Math.max(GAP, screenWidth - totalWidth - GAP));
for (Button button : row) {
button.setPosition(x, y);
x += button.getWidth() + GAP;
}
}

private static BackupClientFacade currentFacade() {
Supplier<? extends BackupClientFacade> supplier = facadeSupplier;
if (supplier == null) {
throw new IllegalStateException("WorldArchive client facade has not been registered");
}
return Objects.requireNonNull(supplier.get(), "facadeSupplier result");
}

private static BooleanSupplier currentLiveWorldAction() {
BooleanSupplier action = openLiveWorldBackups;
if (action == null) {
throw new IllegalStateException("WorldArchive backup action has not been registered");
}
return action;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package dev.ishaanko.worldarchive.ui;

import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.SpriteIconButton;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;

/** Builds the compact WorldArchive shortcut used beside vanilla icon buttons. */
final class WorldArchiveIconButton {
static final int SIZE = 20;

private static final Identifier SPRITE = Identifier.fromNamespaceAndPath(
"worldarchive",
"world_backups");

private WorldArchiveIconButton() {
}

static Button create(int x, int y, int size, Button.OnPress onPress) {
SpriteIconButton button = SpriteIconButton.builder(
Component.translatable("screen.worldarchive.backups_button"),
onPress,
true)
.size(size, size)
.sprite(SPRITE, Math.min(16, size - 4), Math.min(16, size - 4))
.tooltip(Component.translatable("screen.worldarchive.backups_button"))
.build();
button.setRectangle(size, size, x, y);
return button;
}
}
1 change: 1 addition & 0 deletions src/main/resources/assets/worldarchive/lang/en_us.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"modmenu.descriptionTranslation.worldarchive": "Creates dependable local backups of your single-player worlds.",
"screen.worldarchive.backups_button": "World Backups",
"screen.worldarchive.settings.archive_folder": "Archive folder",
"screen.worldarchive.settings.back": "Back",
"screen.worldarchive.settings.browse": "Browse...",
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading