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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## Unreleased

### Added

- The backup toast that appears after you leave a world has a Cancel button.
It stops the backup while the world is copied or while the destinations
write. A destination that had already finished is kept and recorded. Once
the backup is being recorded in the catalog it can no longer be cancelled.

## 0.3.6 (2026-09-08)

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import dev.ishaanko.worldarchive.model.BackupStatus;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CancellationException;

/** Credential-safe notices for unattended backup outcomes. */
final class BackgroundBackupWarnings {
Expand All @@ -19,6 +20,11 @@ static Optional<String> worldExit(BackupResult result, Throwable failure) {
}

static ExitNotice worldExitNotice(BackupResult result, Throwable failure) {
if (isCancellation(failure)) {
return new ExitNotice(
"Backup cancelled; world was saved",
NoticeSeverity.WARNING);
}
if (failure != null || result == null) {
return new ExitNotice(
"World save or backup did not complete",
Expand All @@ -44,10 +50,19 @@ static String worldExitStartedMessage() {
return "Creating backup... Keep Minecraft open until it finishes.";
}

/** A cancellation is the user's own choice, so it never becomes a warning. */
static boolean isCancellation(Throwable failure) {
return failure instanceof CancellationException
|| failure != null && failure.getCause() instanceof CancellationException;
}

private static Optional<String> warning(
String trigger,
BackupResult result,
Throwable failure) {
if (isCancellation(failure)) {
return Optional.empty();
}
if (failure != null || result == null) {
return Optional.of(trigger + " backup did not complete");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import java.util.OptionalDouble;
import java.util.concurrent.atomic.AtomicReference;
import net.minecraft.ChatFormatting;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.toasts.Toast;
Expand All @@ -14,13 +15,19 @@

/**
* Backup toast that stays visible with a live progress bar while an unattended
* backup runs, then shows the color-coded outcome briefly and hides. Progress
* arrives from worker threads; rendering reads one immutable state snapshot.
* backup runs, then shows the color-coded outcome briefly and hides. While the
* backup can still be cancelled the toast draws a Cancel button. Toasts receive
* no input of their own, so the monitor forwards screen clicks to
* {@link #mouseClicked}. Progress arrives from worker threads; rendering reads
* one immutable state snapshot.
*/
final class BackupProgressToast implements Toast {
private static final Component TITLE =
Component.literal("WorldArchive").withStyle(ChatFormatting.BOLD);

private static final Component CANCEL_LABEL =
Component.translatable("screen.worldarchive.backup_toast.cancel");

private static final int WIDTH = 200;

private static final int HEIGHT = 40;
Expand Down Expand Up @@ -49,10 +56,38 @@ final class BackupProgressToast implements Toast {

private static final int BAR_PROGRESS = 0xFF5AAE61;

private static final int BUTTON_TOP = 4;

private static final int BUTTON_HEIGHT = 12;

private static final int BUTTON_PADDING = 4;

private static final int BUTTON_RIGHT_MARGIN = 6;

private static final int BUTTON_BACKGROUND = 0xFF2A2A2A;

private static final int BUTTON_BORDER = 0xFF808080;

private static final int BUTTON_BORDER_HOVERED = 0xFFFFFFFF;

private final Minecraft minecraft;

private final Font font;

private final Runnable cancel;

private final AtomicReference<State> state;

private final int buttonLeft;

private final int buttonWidth;

// Where the toast manager last placed this toast, in GUI coordinates. Written
// and read on the render thread only.
private float left;

private float top;

private long hideAtVisibleMs = Long.MAX_VALUE;

private Toast.Visibility visibility = Toast.Visibility.SHOW;
Expand All @@ -61,19 +96,37 @@ final class BackupProgressToast implements Toast {

private List<FormattedCharSequence> renderedLines = List.of();

BackupProgressToast(Font font, String message) {
this.font = Objects.requireNonNull(font, "font");
this.state = new AtomicReference<>(
new State(message, RUNNING_COLOR, OptionalDouble.empty(), false));
/** A toast that only shows an outcome; it has no Cancel button. */
BackupProgressToast(Minecraft minecraft, String message) {
this(minecraft, message, () -> { }, false);
}

/** A toast for a running backup; {@code cancel} asks that backup to stop. */
BackupProgressToast(Minecraft minecraft, String message, Runnable cancel) {
this(minecraft, message, cancel, true);
}

private BackupProgressToast(
Minecraft minecraft,
String message,
Runnable cancel,
boolean cancellable) {
this.minecraft = Objects.requireNonNull(minecraft, "minecraft");
this.font = minecraft.font;
this.cancel = Objects.requireNonNull(cancel, "cancel");
this.state = new AtomicReference<>(new State(
message, RUNNING_COLOR, OptionalDouble.empty(), false, cancellable, false));
this.buttonWidth = font.width(CANCEL_LABEL) + BUTTON_PADDING * 2;
this.buttonLeft = WIDTH - BUTTON_RIGHT_MARGIN - buttonWidth;
}

/** Updates the live phase text and completed fraction; ignored once finished. */
/** Updates the live phase text and completed fraction; ignored once finished or cancelling. */
void progress(String message, OptionalDouble fraction) {
Objects.requireNonNull(message, "message");
OptionalDouble clamped = clampFraction(fraction);
state.updateAndGet(current -> current.finished()
state.updateAndGet(current -> current.finished() || current.cancelling()
? current
: new State(message, RUNNING_COLOR, clamped, false));
: new State(message, RUNNING_COLOR, clamped, false, current.cancellable(), false));
}

/** Switches to the outcome message; the toast hides a few seconds later. */
Expand All @@ -83,7 +136,41 @@ void finish(String message, BackgroundBackupWarnings.NoticeSeverity severity) {
case WARNING -> WARNING_COLOR;
case ERROR -> ERROR_COLOR;
};
state.set(new State(message, color, OptionalDouble.of(1), true));
state.set(new State(message, color, OptionalDouble.of(1), true, false, false));
}

/**
* Handles a screen click in GUI coordinates. Returns true when the click landed on
* the Cancel button, in which case the backup was asked to stop and the button is gone.
*/
boolean mouseClicked(double mouseX, double mouseY) {
State current = state.get();
if (!current.cancellable() || !overButton(mouseX, mouseY)) {
return false;
}
cancel.run();
state.updateAndGet(latest -> latest.finished()
? latest
: new State(
"Cancelling backup...",
RUNNING_COLOR,
latest.fraction(),
false,
false,
true));
return true;
}

@Override
public float xPos(int screenWidth, float visiblePortion) {
left = Toast.super.xPos(screenWidth, visiblePortion);
return left;
}

@Override
public float yPos(int firstSlotIndex) {
top = Toast.super.yPos(firstSlotIndex);
return top;
}

@Override
Expand Down Expand Up @@ -113,6 +200,9 @@ public void extractRenderState(
graphics.fill(0, 0, WIDTH, HEIGHT, BACKGROUND);
graphics.outline(0, 0, WIDTH, HEIGHT, BORDER);
graphics.text(font, TITLE, TEXT_X, 7, TITLE_COLOR, false);
if (current.cancellable()) {
renderButton(graphics);
}
int y = 19;
for (FormattedCharSequence line : linesFor(current.message())) {
graphics.text(font, line, TEXT_X, y, current.color(), false);
Expand All @@ -123,6 +213,40 @@ public void extractRenderState(
}
}

private void renderButton(GuiGraphicsExtractor graphics) {
boolean hovered = !minecraft.mouseHandler.isMouseGrabbed() && overButton(
minecraft.mouseHandler.getScaledXPos(minecraft.getWindow()),
minecraft.mouseHandler.getScaledYPos(minecraft.getWindow()));
graphics.fill(
buttonLeft,
BUTTON_TOP,
buttonLeft + buttonWidth,
BUTTON_TOP + BUTTON_HEIGHT,
BUTTON_BACKGROUND);
graphics.outline(
buttonLeft,
BUTTON_TOP,
buttonWidth,
BUTTON_HEIGHT,
hovered ? BUTTON_BORDER_HOVERED : BUTTON_BORDER);
graphics.text(
font,
CANCEL_LABEL,
buttonLeft + BUTTON_PADDING,
BUTTON_TOP + 2,
TITLE_COLOR,
false);
}

private boolean overButton(double mouseX, double mouseY) {
double x = mouseX - left;
double y = mouseY - top;
return x >= buttonLeft
&& x < buttonLeft + buttonWidth
&& y >= BUTTON_TOP
&& y < BUTTON_TOP + BUTTON_HEIGHT;
}

private void renderBar(
GuiGraphicsExtractor graphics,
OptionalDouble fraction,
Expand Down Expand Up @@ -173,7 +297,17 @@ private static OptionalDouble clampFraction(OptionalDouble fraction) {
return OptionalDouble.of(Math.clamp(fraction.orElseThrow(), 0, 1));
}

private record State(String message, int color, OptionalDouble fraction, boolean finished) {
/**
* One render snapshot. {@code cancellable} draws the button; {@code cancelling} holds
* the "Cancelling" text until the outcome arrives.
*/
private record State(
String message,
int color,
OptionalDouble fraction,
boolean finished,
boolean cancellable,
boolean cancelling) {
private State {
Objects.requireNonNull(message, "message");
Objects.requireNonNull(fraction, "fraction");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,12 @@
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BiConsumer;
import java.util.function.BooleanSupplier;
import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents;
import net.fabricmc.fabric.api.client.screen.v1.ScreenMouseEvents;
import net.minecraft.ChatFormatting;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.input.MouseButtonEvent;
import net.minecraft.network.chat.Component;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand All @@ -32,6 +36,8 @@ final class RuntimeBackgroundBackupMonitor {
private static final Logger LOGGER =
LoggerFactory.getLogger(WorldArchiveMetadata.MOD_NAME);

private static final int LEFT_MOUSE_BUTTON = 0;

private final Minecraft minecraft;

private final RuntimeNoticeStore noticeStore;
Expand Down Expand Up @@ -71,6 +77,26 @@ final class RuntimeBackgroundBackupMonitor {
} catch (IOException exception) {
LOGGER.warn("Stored background backup notice could not be loaded");
}
ScreenEvents.AFTER_INIT.register(this::routeScreenClicks);
}

// Toasts get no input of their own, so every screen forwards its clicks to the
// active progress toasts. A click on a Cancel button is consumed here.
private void routeScreenClicks(Minecraft ignored, Screen screen, int width, int height) {
ScreenMouseEvents.allowMouseClick(screen)
.register((ignoredScreen, event) -> !clickToast(event));
}

private boolean clickToast(MouseButtonEvent event) {
if (event.button() != LEFT_MOUSE_BUTTON || closed.getAsBoolean()) {
return false;
}
for (BackupProgressToast toast : activeToasts.values()) {
if (toast.mouseClicked(event.x(), event.y())) {
return true;
}
}
return false;
}

Optional<String> warning() {
Expand Down Expand Up @@ -147,7 +173,9 @@ private void observeExitResult(
Object progressKey,
BackupResult result,
Throwable throwable) {
if (throwable != null) {
if (BackgroundBackupWarnings.isCancellation(throwable)) {
LOGGER.info("World-exit backup was cancelled");
} else if (throwable != null) {
failureLogger.accept(
"World-exit backup did not complete",
throwable);
Expand Down Expand Up @@ -193,17 +221,21 @@ void showRetainedWarning() {
}
}

/** Shows the persistent progress toast for an unattended backup that just started. */
void beginBackupProgress(String message, Object progressKey) {
/**
* Shows the persistent progress toast for an unattended backup that just started.
* {@code cancel} is run when the user presses the toast's Cancel button.
*/
void beginBackupProgress(String message, Object progressKey, Runnable cancel) {
Objects.requireNonNull(progressKey, "progressKey");
Objects.requireNonNull(cancel, "cancel");
if (closed.getAsBoolean()) {
return;
}
minecraft.execute(() -> {
if (closed.getAsBoolean()) {
return;
}
BackupProgressToast toast = new BackupProgressToast(minecraft.font, message);
BackupProgressToast toast = new BackupProgressToast(minecraft, message, cancel);
activeToasts.put(progressKey, toast);
minecraft.gui.toastManager().addToast(toast);
});
Expand Down Expand Up @@ -249,7 +281,7 @@ private void showBackupNotice(
? null
: activeToasts.remove(progressKey);
if (toast == null) {
toast = new BackupProgressToast(minecraft.font, notice.message());
toast = new BackupProgressToast(minecraft, notice.message());
minecraft.gui.toastManager().addToast(toast);
}
toast.finish(notice.message(), notice.severity());
Expand Down
Loading
Loading