init
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user