This commit is contained in:
iceBear67
2026-08-07 18:10:38 +00:00
commit 014b18f1ba
232 changed files with 16847 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
// The seam. Interfaces that platform modules implement and the UI consumes.
// Nothing in this module may import a Minecraft or Fabric type.
dependencies {
api project(':shared:core')
}
@@ -0,0 +1,28 @@
package dev.photosync.mcapi;
import dev.photosync.mcapi.capture.ScreenshotService;
import dev.photosync.mcapi.render.TextureSink;
import dev.photosync.mcapi.screen.ScreenHost;
/**
* One object holding every version-specific service, built once by the platform
* module at client startup.
*
* <p>It exists so that a screen takes a single constructor argument instead of
* five, and so that the list of things a new Minecraft version has to provide is
* readable in one place -- this interface is the porting checklist.
*/
public interface ClientBridge {
GameContext game();
Translator text();
Clipboard clipboard();
TextureSink textures();
ScreenHost screens();
ScreenshotService screenshots();
}
@@ -0,0 +1,15 @@
package dev.photosync.mcapi;
/**
* The system clipboard.
*
* <p>Worth a seam of its own for one reason: an Immich API key is a long random
* string that nobody types by hand, so paste has to work in the settings screen
* or the mod is unusable.
*/
public interface Clipboard {
String read();
void write(String text);
}
@@ -0,0 +1,45 @@
package dev.photosync.mcapi;
import java.nio.file.Path;
/** The ambient client state PhotoSync has to consult, and the render thread. */
public interface GameContext {
/** True once a world is loaded and being rendered. */
boolean inWorld();
/** True while any screen is up, vanilla's or ours. Automatic capture checks this. */
boolean screenOpen();
/** {@code .minecraft/config}, where PhotoSync keeps its settings and upload queue. */
Path configDirectory();
/**
* Runs a task on the render thread, or immediately if already on it.
*
* <p>Anything that touches a texture, a screen or the framebuffer has to go
* through here, because everything that produces such work in this mod --
* uploads, thumbnail fetches, the capture timer -- runs on a worker.
*/
void submit(Runnable task);
/**
* Opens a file or folder in the desktop's file manager.
*
* <p>The queue screen offers this for a screenshot the player is looking at,
* because the alternative to a working "show in folder" is explaining where
* {@code .minecraft} lives.
*/
void reveal(Path path);
/** The running Minecraft version, for logs and the settings screen's footer. */
String minecraftVersion();
/**
* Shuts the game down, having already opened {@code QuitGuard}'s gate.
*
* <p>This is how the quit dialog finishes what the player started once the
* uploads are done or they have chosen not to wait.
*/
void quit();
}
@@ -0,0 +1,56 @@
package dev.photosync.mcapi;
/**
* The GLFW key and modifier codes the UI reacts to.
*
* <p>They are named here rather than read from LWJGL because {@code :shared:ui}
* deliberately has no LWJGL on its classpath, and because {@code key == 259} at
* a call site is unreadable in a way {@code key == Keys.BACKSPACE} is not. The
* numbers are fixed by GLFW's ABI and have not changed in the library's history.
*/
public final class Keys {
public static final int ESCAPE = 256;
public static final int ENTER = 257;
public static final int TAB = 258;
public static final int BACKSPACE = 259;
public static final int DELETE = 261;
public static final int RIGHT = 262;
public static final int LEFT = 263;
public static final int DOWN = 264;
public static final int UP = 265;
public static final int PAGE_UP = 266;
public static final int PAGE_DOWN = 267;
public static final int HOME = 268;
public static final int END = 269;
public static final int KEYPAD_ENTER = 335;
public static final int A = 65;
public static final int C = 67;
public static final int V = 86;
public static final int X = 88;
public static final int MOD_SHIFT = 0x1;
public static final int MOD_CONTROL = 0x2;
public static final int MOD_ALT = 0x4;
public static final int MOD_SUPER = 0x8;
private Keys() {
}
/**
* Whether the copy/paste modifier is held: Control everywhere, and Command
* on macOS, where GLFW reports it as Super.
*/
public static boolean shortcut(int modifiers) {
return (modifiers & (MOD_CONTROL | MOD_SUPER)) != 0;
}
public static boolean shift(int modifiers) {
return (modifiers & MOD_SHIFT) != 0;
}
public static boolean confirms(int key) {
return key == ENTER || key == KEYPAD_ENTER;
}
}
@@ -0,0 +1,16 @@
package dev.photosync.mcapi;
/**
* Looks up translated strings.
*
* <p>The UI works in plain {@code String}s rather than Minecraft's text
* components: everything PhotoSync displays is a translated line with a colour
* chosen by the caller, and nothing needs a hover event or a click event.
*/
public interface Translator {
/** The translation for {@code key}, or the key itself when it is missing. */
String get(String key, Object... arguments);
boolean has(String key);
}
@@ -0,0 +1,72 @@
package dev.photosync.mcapi.capture;
import dev.photosync.core.capture.CaptureOrigin;
import dev.photosync.core.capture.CapturedScreenshot;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Consumer;
/**
* Where a screenshot the player took is announced.
*
* <p>This is the mod's one singleton, and it exists for a specific reason: the
* thing that knows a screenshot was saved is a mixin, and a mixin is woven into
* a Minecraft class that nobody constructs, so it has nowhere to be handed a
* collaborator. A static rendezvous point is the only option, and confining it
* to this one class is what stops that fact from spreading.
*
* <p>Every platform module's {@code Screenshot} mixin calls {@link #published}
* with the same two arguments, which is why the mixins stay a handful of lines
* each no matter how the surrounding Minecraft code is reshaped.
*
* <p>Automatic captures do not come through here -- they are published by
* whoever called {@link ScreenshotService#capture}, which already knows their
* origin.
*/
@Slf4j
public final class ScreenshotBus {
private static final ScreenshotBus INSTANCE = new ScreenshotBus();
private final List<Consumer<CapturedScreenshot>> listeners = new CopyOnWriteArrayList<>();
private ScreenshotBus() {
}
public static ScreenshotBus get() {
return INSTANCE;
}
public void subscribe(Consumer<CapturedScreenshot> listener) {
listeners.add(listener);
}
/**
* Announces a saved screenshot. Called from the IO thread that wrote it, so
* subscribers must be thread-safe.
*
* <p>Swallows failures on purpose: a screenshot the mod could not stat is
* still a screenshot the player successfully took, and throwing back into a
* mixin would turn a sync problem into a vanilla one.
*/
public void published(Path file, CaptureOrigin origin) {
CapturedScreenshot shot;
try {
shot = CapturedScreenshot.of(file, origin);
} catch (IOException e) {
log.warn("Ignoring a screenshot that could not be read back: {}", file, e);
return;
}
for (Consumer<CapturedScreenshot> listener : listeners) {
try {
listener.accept(shot);
} catch (RuntimeException e) {
log.error("A screenshot listener failed for {}", file, e);
}
}
}
}
@@ -0,0 +1,31 @@
package dev.photosync.mcapi.capture;
import java.nio.file.Path;
import java.util.concurrent.CompletableFuture;
/**
* Takes a screenshot on demand -- the automatic capture timer's only way into
* the game.
*
* <p>Deliberately not routed through vanilla's own screenshot call. Going
* straight to the framebuffer lets the adapter choose the file name, which is
* what makes the configurable suffix possible, and it keeps automatic captures
* out of the {@link ScreenshotBus} path that the mixin owns -- so the origin of
* a file is known by construction rather than guessed from its name.
*/
public interface ScreenshotService {
/** Where the game keeps screenshots. Created if it does not exist. */
Path directory();
/**
* Captures the current frame.
*
* <p>Safe to call from any thread; the grab itself is moved onto the render
* thread. The future completes once the PNG is on disk, or fails if there is
* no frame to capture or the write failed.
*
* @param fileNameSuffix inserted before the extension, e.g. {@code "_auto"}
*/
CompletableFuture<Path> capture(String fileNameSuffix);
}
@@ -0,0 +1,73 @@
package dev.photosync.mcapi.lifecycle;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* The gate a mixin on the game's exit path asks before letting the process go
* away.
*
* <p>A screenshot that was still uploading when the player pressed "Quit Game"
* is the one case where doing nothing loses data the player can see they took.
* The queue is durable, so nothing is truly lost, but "it will finish next time
* you play" is a worse answer than "give it four more seconds", and the player
* cannot make that choice unless something stops the shutdown long enough to ask.
*
* <p>Static for the same reason as {@code ScreenshotBus}: the caller is woven
* into a Minecraft class and has nothing to be injected with. Both are confined
* to {@code mc-api} so the rest of the mod stays constructor-wired.
*
* <p>The protocol is deliberately one-shot. Once the player has answered -- by
* waiting or by insisting -- {@link #allowOnce()} opens the gate for exactly the
* next attempt, so a second quit later in the session is questioned again.
*/
@Slf4j
public final class QuitGuard {
private static final QuitGuard INSTANCE = new QuitGuard();
/** Answers whether the game may shut down, and takes over the interaction if not. */
@FunctionalInterface
public interface Handler {
boolean mayQuit();
}
private final AtomicBoolean approved = new AtomicBoolean();
private volatile Handler handler = () -> true;
private QuitGuard() {
}
public static QuitGuard get() {
return INSTANCE;
}
/** Installed once, at client startup. */
public void handler(Handler handler) {
this.handler = handler;
}
/**
* Called from the exit path. {@code false} means PhotoSync has taken over
* and the mixin should cancel the shutdown; the mod will come back through
* here once the player has decided.
*/
public boolean mayQuit() {
if (approved.getAndSet(false)) {
return true;
}
try {
return handler.mayQuit();
} catch (RuntimeException e) {
// Never trap the player in a game that will not close.
log.error("The quit handler failed; letting the game shut down", e);
return true;
}
}
/** Lets exactly the next {@link #mayQuit()} through. */
public void allowOnce() {
approved.set(true);
}
}
@@ -0,0 +1,20 @@
/**
* The seam between PhotoSync and Minecraft.
*
* <p>Every interface here is implemented once per compatibility bucket, in a
* {@code :platform:*} module, and consumed by {@code :shared:ui} and the mod's
* wiring. Nothing in this module may import a Minecraft, Fabric or LWJGL type --
* that is what keeps the amount of code that has to be revisited for a new
* Minecraft version down to the adapters rather than the whole mod.
*
* <p>Two rules keep the seam from growing. A method belongs here only if its
* Minecraft implementation genuinely differs between versions -- anything that
* can be computed from what is already here belongs in {@code :shared:ui}
* instead. And nothing here exposes a Minecraft concept by another name: the
* mod draws its own widgets from a handful of primitives rather than describing
* vanilla ones, because {@code Button}'s constructor has changed more often in
* this version range than {@code fill} has.
*
* <p>See {@code docs/PORTING.md} for the routine when a new version lands.
*/
package dev.photosync.mcapi;
@@ -0,0 +1,64 @@
package dev.photosync.mcapi.render;
/**
* The drawing primitives every PhotoSync screen is built from.
*
* <p>Eleven methods, chosen because each one maps to something Minecraft has had
* continuously since 1.20 even as the class holding it was renamed, moved behind
* a render pipeline, or had its parameters reordered. Everything else the UI
* draws -- buttons, scrollbars, text fields, tooltips, the timeline grid -- is
* composed from these in {@code :shared:ui}, so a new Minecraft version costs
* one adapter rather than one widget set.
*
* <p>Colours are packed 0xAARRGGBB. Coordinates are in GUI space, already
* divided by the GUI scale, with the origin at the top-left.
*
* <p>An instance is only valid for the duration of the render call it was handed
* to. Holding one past that draws into a frame that no longer exists.
*/
public interface RenderBridge {
/** Width of the drawable area in GUI space. */
int width();
/** Height of the drawable area in GUI space. */
int height();
/** Fraction of a tick elapsed since the last one, for smooth animation. */
float tickDelta();
void fill(int x, int y, int width, int height, int argb);
/** A vertical gradient. Cheap polish that would otherwise cost three fills and still look flat. */
void gradient(int x, int y, int width, int height, int topArgb, int bottomArgb);
/** A one-pixel outline drawn just inside the given rectangle. */
void border(int x, int y, int width, int height, int argb);
void text(String text, int x, int y, int argb, boolean shadow);
/** Width of {@code text} in GUI pixels, for every layout decision the UI makes. */
int textWidth(String text);
/** Height of one line of text including its leading. */
int lineHeight();
void image(TextureHandle texture, int x, int y, int width, int height);
/**
* Draws part of a texture, with UVs in the 0..1 range.
*
* <p>The timeline crops tiles to a square this way instead of squashing
* them, which is the whole reason the region variant exists.
*/
void image(TextureHandle texture, int x, int y, int width, int height,
float u0, float v0, float u1, float v1);
/**
* Clips subsequent drawing to a rectangle until the matching
* {@link #popClip()}. Nests.
*/
void pushClip(int x, int y, int width, int height);
void popClip();
}
@@ -0,0 +1,20 @@
package dev.photosync.mcapi.render;
/**
* A texture living on the GPU, owned by whoever asked {@link TextureSink} for it.
*
* <p>Closing it is not optional: the browser can walk through thousands of
* thumbnails in a session, and a leaked texture is leaked video memory for as
* long as the game runs. The UI's cache is what closes these, on eviction and
* when its screen goes away.
*/
public interface TextureHandle extends AutoCloseable {
int width();
int height();
/** Releases the GPU resource. Must be called on the render thread. Idempotent. */
@Override
void close();
}
@@ -0,0 +1,31 @@
package dev.photosync.mcapi.render;
import dev.photosync.core.thumbnail.ThumbImage;
import java.io.IOException;
/**
* Turns pixels into something drawable.
*
* <p>Both methods must be called on the render thread, which is why the loaders
* in {@code :shared:core} deal in bytes and leave the upload to the UI: network
* work happens on a worker, and only the last cheap step crosses back onto the
* thread that can actually talk to the GPU.
*/
public interface TextureSink {
/**
* Uploads a decoded placeholder -- at most 32x32 -- from a ThumbHash.
*
* <p>Kept separate from {@link #decode} because these are already pixels and
* routing them through an image decoder would mean encoding a PNG first.
*/
TextureHandle upload(ThumbImage image);
/**
* Decodes and uploads PNG or JPEG bytes.
*
* @throws IOException if the bytes are not an image the game can read
*/
TextureHandle decode(byte[] encoded) throws IOException;
}
@@ -0,0 +1,15 @@
package dev.photosync.mcapi.screen;
import java.util.Optional;
/** Opens and closes {@link ScreenModel}s. Call on the render thread. */
public interface ScreenHost {
void open(ScreenModel screen);
/** Closes whatever is open, returning the player to the game. */
void close();
/** The PhotoSync screen currently open, if the open screen is one of ours. */
Optional<ScreenModel> current();
}
@@ -0,0 +1,74 @@
package dev.photosync.mcapi.screen;
import dev.photosync.mcapi.render.RenderBridge;
/**
* A PhotoSync screen, in terms that owe nothing to Minecraft.
*
* <p>Each platform module has exactly one class that extends Minecraft's
* {@code Screen} and forwards its lifecycle and input here. That class is the
* only thing about the mod's entire interface that a new Minecraft version can
* break.
*
* <p>The input methods answer whether they consumed the event, matching what
* vanilla screens expect, so the adapter can pass the result straight through.
*/
public interface ScreenModel {
/** Shown in the window title and read by screen readers. Already translated. */
String title();
/**
* Called when the screen opens and again on every resize, with the current
* GUI dimensions. Everything laid out in pixels should be computed here.
*/
void layout(int width, int height);
void render(RenderBridge render, int mouseX, int mouseY);
/** Once per client tick, for cursor blink and other time-based state. */
default void tick() {
}
default boolean mouseClicked(double mouseX, double mouseY, int button) {
return false;
}
default boolean mouseReleased(double mouseX, double mouseY, int button) {
return false;
}
default boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
return false;
}
/** {@code amount} is positive when scrolling up, as vanilla reports it. */
default boolean mouseScrolled(double mouseX, double mouseY, double amount) {
return false;
}
default boolean keyPressed(int key, int scanCode, int modifiers) {
return false;
}
default boolean charTyped(char character, int modifiers) {
return false;
}
/** The screen is going away, for any reason. Release textures here. */
default void closed() {
}
/** Whether opening this screen should pause a singleplayer world. */
default boolean pausesGame() {
return true;
}
/**
* Whether Escape closes the screen. False while a modal is up -- the quit
* dialog in particular, which has to be answered rather than dismissed.
*/
default boolean closeOnEscape() {
return true;
}
}