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
+6
View File
@@ -0,0 +1,6 @@
// Screens, layout and interaction, drawn entirely through mc-api's RenderBridge.
// Nothing in this module may import a Minecraft or Fabric type.
dependencies {
api project(':shared:core')
api project(':shared:mc-api')
}
@@ -0,0 +1,203 @@
package dev.photosync.ui;
import dev.photosync.mcapi.Translator;
import dev.photosync.mcapi.render.RenderBridge;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.experimental.Accessors;
/**
* PhotoSync's visual vocabulary: the handful of compound shapes that appear on
* more than one screen, drawn on top of {@code RenderBridge}'s primitives.
*
* <p>This is not a bag of helpers. It is the object that knows what a PhotoSync
* panel looks like, and it owns the {@link Theme} because every one of these
* decisions is a theme decision. Widgets and screens each hold one; nothing in
* the module reaches for a colour without going through it.
*/
@Getter
@Accessors(fluent = true)
@RequiredArgsConstructor
public final class Chrome {
private final Theme theme;
private final Translator text;
/** Shorthand for the common case of a key with no arguments. */
public String translate(String key, Object... arguments) {
return text.get(key, arguments);
}
// -----------------------------------------------------------------------
// Surfaces
// -----------------------------------------------------------------------
/** Dims the whole screen so the panel reads as being in front of the world. */
public void scrim(RenderBridge render) {
render.fill(0, 0, render.width(), render.height(), theme.scrim());
}
/** The main window: filled body, one-pixel border, header strip along the top. */
public void panel(RenderBridge render, Rect bounds) {
render.fill(bounds.x(), bounds.y(), bounds.width(), bounds.height(), theme.panel());
render.border(bounds.x(), bounds.y(), bounds.width(), bounds.height(), theme.panelBorder());
}
/** A recessed area -- list viewports and the timeline grid sit in one of these. */
public void well(RenderBridge render, Rect bounds) {
render.fill(bounds.x(), bounds.y(), bounds.width(), bounds.height(), theme.surfaceSunken());
}
public void divider(RenderBridge render, int x, int y, int width) {
render.fill(x, y, width, 1, theme.panelBorder());
}
// -----------------------------------------------------------------------
// Text
// -----------------------------------------------------------------------
public void label(RenderBridge render, String value, int x, int y, int argb) {
render.text(value, x, y, argb, true);
}
/** Draws {@code value} centred horizontally within {@code bounds}. */
public void centered(RenderBridge render, String value, Rect bounds, int argb) {
int x = bounds.x() + (bounds.width() - render.textWidth(value)) / 2;
int y = bounds.y() + (bounds.height() - render.lineHeight()) / 2 + 1;
render.text(value, x, y, argb, true);
}
/** Draws {@code value}, vertically centred in {@code bounds} and clipped to its width. */
public void fitted(RenderBridge render, String value, Rect bounds, int argb) {
int y = bounds.y() + (bounds.height() - render.lineHeight()) / 2 + 1;
render.text(elide(render, value, bounds.width()), bounds.x(), y, argb, true);
}
/**
* Shortens {@code value} with an ellipsis until it fits.
*
* <p>Needed constantly: file names, album names, error messages and server
* URLs are all attacker-of-layout length, and the alternative to eliding is
* text spilling across a neighbouring column.
*/
public String elide(RenderBridge render, String value, int maxWidth) {
if (render.textWidth(value) <= maxWidth) {
return value;
}
String ellipsis = "...";
int budget = maxWidth - render.textWidth(ellipsis);
if (budget <= 0) {
return "";
}
// Linear from the end rather than a binary search: these strings are
// short, and this runs a few dozen times a frame at most.
int end = value.length();
while (end > 0 && render.textWidth(value.substring(0, end)) > budget) {
end--;
}
return value.substring(0, end) + ellipsis;
}
/** Wraps to at most {@code maxLines}, eliding the last one if it still overflows. */
public java.util.List<String> wrap(RenderBridge render, String value, int maxWidth, int maxLines) {
java.util.List<String> lines = new java.util.ArrayList<>();
StringBuilder line = new StringBuilder();
for (String word : value.split("\\s+")) {
String candidate = line.isEmpty() ? word : line + " " + word;
if (render.textWidth(candidate) <= maxWidth || line.isEmpty()) {
line.setLength(0);
line.append(candidate);
} else {
lines.add(line.toString());
line.setLength(0);
line.append(word);
if (lines.size() == maxLines - 1) {
break;
}
}
}
if (!line.isEmpty() && lines.size() < maxLines) {
lines.add(lines.size() == maxLines - 1 ? elide(render, line.toString(), maxWidth) : line.toString());
}
return lines;
}
// -----------------------------------------------------------------------
// Indicators
// -----------------------------------------------------------------------
/** A filled bar. {@code fraction} is clamped, so a bad total cannot draw outside. */
public void progressBar(RenderBridge render, Rect bounds, double fraction, int argb) {
render.fill(bounds.x(), bounds.y(), bounds.width(), bounds.height(), theme.surfaceSunken());
int filled = (int) Math.round(bounds.width() * Math.max(0.0, Math.min(1.0, fraction)));
if (filled > 0) {
render.fill(bounds.x(), bounds.y(), filled, bounds.height(), argb);
}
}
/**
* An indeterminate bar, for work whose size is not known yet.
*
* <p>Driven by wall-clock time rather than a tick counter so it keeps moving
* while the game is paused -- which is exactly when the player is looking at
* a PhotoSync screen.
*/
public void busyBar(RenderBridge render, Rect bounds, long nowMillis, int argb) {
render.fill(bounds.x(), bounds.y(), bounds.width(), bounds.height(), theme.surfaceSunken());
int span = Math.max(8, bounds.width() / 4);
int travel = bounds.width() + span;
int offset = (int) ((nowMillis / 4) % travel) - span;
int start = Math.max(bounds.x(), bounds.x() + offset);
int end = Math.min(bounds.right(), bounds.x() + offset + span);
if (end > start) {
render.fill(start, bounds.y(), end - start, bounds.height(), argb);
}
}
/** A small filled pill with a label, used for states and counts. */
public void badge(RenderBridge render, String value, int x, int y, int background, int foreground) {
int width = render.textWidth(value) + 6;
int height = render.lineHeight() + 2;
render.fill(x, y, width, height, background);
render.text(value, x + 3, y + 2, foreground, false);
}
/**
* The marker drawn over a video's still preview.
*
* <p>PhotoSync never plays video, so this is the whole of its video support:
* a triangle in the corner that says "this is a clip, go and watch it
* somewhere that can".
*/
public void videoMarker(RenderBridge render, Rect tile, String duration) {
int size = 9;
int x = tile.right() - size - 3;
int y = tile.bottom() - size - 3;
render.fill(x - 1, y - 1, size + 2, size + 2, theme.overlay());
// A play triangle from horizontal runs -- there is no primitive for a
// polygon, and at nine pixels nobody can tell the difference.
for (int row = 0; row < size; row++) {
int distance = Math.abs(row - size / 2);
int length = Math.max(1, (size / 2) - distance + 1);
render.fill(x + 2, y + row, length, 1, theme.text());
}
if (duration != null && !duration.isEmpty()) {
int width = render.textWidth(duration);
render.fill(tile.x() + 2, tile.bottom() - render.lineHeight() - 3, width + 4, render.lineHeight() + 2,
theme.overlay());
render.text(duration, tile.x() + 4, tile.bottom() - render.lineHeight() - 2, theme.text(), false);
}
}
/** Centred message for an empty list, a load failure, or "not configured yet". */
public void notice(RenderBridge render, Rect bounds, String headline, String detail) {
int lineHeight = render.lineHeight();
int totalHeight = detail == null || detail.isEmpty() ? lineHeight : lineHeight * 2 + 3;
int top = bounds.y() + (bounds.height() - totalHeight) / 2;
centered(render, headline, new Rect(bounds.x(), top, bounds.width(), lineHeight), theme.textMuted());
if (detail != null && !detail.isEmpty()) {
centered(render, elide(render, detail, bounds.width() - 16),
new Rect(bounds.x(), top + lineHeight + 3, bounds.width(), lineHeight), theme.textFaint());
}
}
}
@@ -0,0 +1,221 @@
package dev.photosync.ui;
import dev.photosync.core.PhotoSync;
import dev.photosync.core.config.PhotoSyncConfig;
import dev.photosync.core.provider.Album;
import dev.photosync.core.provider.AlbumRef;
import dev.photosync.mcapi.ClientBridge;
import dev.photosync.mcapi.GameContext;
import dev.photosync.mcapi.lifecycle.QuitGuard;
import dev.photosync.ui.notify.Notifications;
import dev.photosync.ui.screen.AlbumPickerScreen;
import dev.photosync.ui.screen.PhotoSyncScreen;
import dev.photosync.ui.screen.PhotoSyncScreen.Tab;
import dev.photosync.ui.screen.QueueScreen;
import dev.photosync.ui.screen.QuitDialog;
import dev.photosync.ui.screen.SettingsScreen;
import dev.photosync.ui.screen.TimelineScreen;
import lombok.Getter;
import lombok.experimental.Accessors;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* What the screens share: the game, the core, and the handful of decisions that
* outlive any one of them.
*
* <p>Screens are cheap and short-lived -- switching tabs builds a new one -- so
* anything that must survive a tab switch lives here rather than in a screen.
* That is three things: which album the browser is pointed at, the settings
* draft, and the album names the picker has seen.
*
* <p>The draft deserves a word. It is null while the saved config is what the
* player is looking at, and non-null once they have edited something. That makes
* "is there anything to save?" a null check plus an equality test on two records,
* rather than a dirty flag that someone has to remember to clear.
*/
@Accessors(fluent = true)
public final class PhotoSyncUi {
@Getter
private final PhotoSync core;
@Getter
private final ClientBridge bridge;
@Getter
private final Chrome chrome;
@Getter
private final Notifications notifications;
/** Album display names learned from the picker, so settings can show one. */
private final Map<String, String> albumNames = new ConcurrentHashMap<>();
/**
* Whether the browser is showing everything rather than the upload album.
* A view preference, not a setting -- it is not worth persisting, and the
* album the player configured is the right thing to open on.
*/
@Getter
private boolean browsingLibrary;
private PhotoSyncConfig draft;
private AlbumRef browsingTarget;
public PhotoSyncUi(PhotoSync core, ClientBridge bridge, Theme theme) {
this.core = core;
this.bridge = bridge;
this.chrome = new Chrome(theme, bridge.text());
this.notifications = new Notifications(chrome, () -> core.config().current().notifications());
}
public GameContext game() {
return bridge.game();
}
// -----------------------------------------------------------------------
// Navigation
// -----------------------------------------------------------------------
/** The entry point the key binding calls. */
public void open() {
open(core.config().current().isReady() ? Tab.QUEUE : Tab.SETTINGS);
}
public void open(Tab tab) {
bridge.screens().open(screenFor(tab));
}
public void openAlbumPicker() {
bridge.screens().open(new AlbumPickerScreen(this));
}
public void close() {
bridge.screens().close();
}
private PhotoSyncScreen screenFor(Tab tab) {
return switch (tab) {
case QUEUE -> new QueueScreen(this);
case BROWSE -> new TimelineScreen(this);
case SETTINGS -> new SettingsScreen(this);
};
}
// -----------------------------------------------------------------------
// Browsing
// -----------------------------------------------------------------------
public void browsingLibrary(boolean value) {
this.browsingLibrary = value;
ensureBrowsing();
}
/**
* Points the browser at whatever the player is currently asking to see.
*
* <p>Called on every layout, so it has to be free when nothing changed:
* {@code TimelineBrowser.open} throws away everything it has loaded, which
* would turn a window resize into a full reload.
*/
public void ensureBrowsing() {
AlbumRef target = browsingLibrary ? AlbumRef.library() : core.config().current().album();
if (target.equals(browsingTarget)) {
return;
}
browsingTarget = target;
core.browser().open(target);
}
// -----------------------------------------------------------------------
// The settings draft
// -----------------------------------------------------------------------
/** The config the settings screen is editing: the draft, or the saved one. */
public PhotoSyncConfig draft() {
return draft == null ? core.config().current() : draft;
}
public void draft(PhotoSyncConfig edited) {
this.draft = edited;
}
public boolean draftIsDirty() {
return draft != null && !draft.equals(core.config().current());
}
/** Throws the edits away; the screen rebuilds from the saved config. */
public void resetDraft() {
this.draft = null;
}
/**
* Saves the draft. Called by the Save button and again when the settings
* screen closes, so an edit cannot be lost by pressing Escape -- which is
* what a player who has finished typing will do.
*/
public void applyDraft() {
PhotoSyncConfig pending = draft;
this.draft = null;
if (pending == null || pending.equals(core.config().current())) {
return;
}
core.config().update(current -> pending);
// The album may have moved with it, and the browser is showing the old one.
ensureBrowsing();
}
// -----------------------------------------------------------------------
// Albums
// -----------------------------------------------------------------------
public void rememberAlbums(List<Album> albums) {
albums.forEach(album -> albumNames.put(album.id(), album.name()));
}
/** What the settings screen's album button says. */
public String albumLabel() {
String id = draft().albumId();
if (id.isEmpty()) {
return chrome.translate("photosync.album.library");
}
// The id is a poor label, but it is honest: it means the player picked
// this album on another machine and we have not seen the list yet.
return albumNames.getOrDefault(id, id);
}
/** Chosen from the server's own list, so it is saved rather than drafted. */
public void chooseAlbum(String albumId) {
core.config().update(current -> current.toBuilder().albumId(albumId).build());
if (draft != null) {
draft = draft.toBuilder().albumId(albumId).build();
}
ensureBrowsing();
}
// -----------------------------------------------------------------------
// Quitting
// -----------------------------------------------------------------------
/**
* Puts the quit dialog up. Called from the {@link QuitGuard} handler.
*
* <p>Idempotent, and it has to be: closing the window sets a flag GLFW keeps
* set, so the client asks the guard again on every frame until it either
* gets a yes or the player clicks something. Re-opening the dialog each time
* would reset its state and eat the click.
*/
public void confirmQuit() {
if (bridge.screens().current().filter(QuitDialog.class::isInstance).isPresent()) {
return;
}
bridge.screens().open(new QuitDialog(this));
}
/** Opens the gate and lets the game go. */
public void quitNow() {
QuitGuard.get().allowOnce();
bridge.screens().close();
game().quit();
}
}
@@ -0,0 +1,101 @@
package dev.photosync.ui;
/**
* An axis-aligned rectangle in GUI space.
*
* <p>Layout in this module is arithmetic on these rather than a constraint
* solver: the screens are simple enough that a solver would be more machinery
* than the problem deserves, and every position stays inspectable in a debugger.
*/
public record Rect(int x, int y, int width, int height) {
public static final Rect EMPTY = new Rect(0, 0, 0, 0);
public int right() {
return x + width;
}
public int bottom() {
return y + height;
}
public int centerX() {
return x + width / 2;
}
public int centerY() {
return y + height / 2;
}
public boolean isEmpty() {
return width <= 0 || height <= 0;
}
/** Mouse coordinates arrive as doubles, so this takes them as they come. */
public boolean contains(double pointX, double pointY) {
return pointX >= x && pointX < right() && pointY >= y && pointY < bottom();
}
/** Shrinks on all four sides. A negative amount grows instead. */
public Rect inset(int amount) {
return inset(amount, amount, amount, amount);
}
public Rect inset(int left, int top, int right, int bottom) {
return new Rect(x + left, y + top, width - left - right, height - top - bottom);
}
public Rect translate(int deltaX, int deltaY) {
return new Rect(x + deltaX, y + deltaY, width, height);
}
public Rect withHeight(int newHeight) {
return new Rect(x, y, width, newHeight);
}
public Rect withWidth(int newWidth) {
return new Rect(x, y, newWidth, height);
}
/** The top strip of this rectangle. */
public Rect top(int amount) {
return new Rect(x, y, width, Math.min(amount, height));
}
/** The bottom strip of this rectangle. */
public Rect bottom(int amount) {
int taken = Math.min(amount, height);
return new Rect(x, bottom() - taken, width, taken);
}
/** The left column of this rectangle. */
public Rect left(int amount) {
return new Rect(x, y, Math.min(amount, width), height);
}
/** The right column of this rectangle. */
public Rect right(int amount) {
int taken = Math.min(amount, width);
return new Rect(right() - taken, y, taken, height);
}
/** What is left after taking {@code amount} off the top. */
public Rect dropTop(int amount) {
return new Rect(x, y + amount, width, Math.max(0, height - amount));
}
/** What is left after taking {@code amount} off the bottom. */
public Rect dropBottom(int amount) {
return new Rect(x, y, width, Math.max(0, height - amount));
}
/** What is left after taking {@code amount} off the left. */
public Rect dropLeft(int amount) {
return new Rect(x + amount, y, Math.max(0, width - amount), height);
}
/** What is left after taking {@code amount} off the right. */
public Rect dropRight(int amount) {
return new Rect(x, y, Math.max(0, width - amount), height);
}
}
@@ -0,0 +1,102 @@
package dev.photosync.ui;
import lombok.Builder;
/**
* Every colour and metric the interface uses, in one value.
*
* <p>Gathered here rather than spread across the widgets so that a colour has a
* name and a single definition, and so that a screen cannot quietly invent its
* own shade of grey. Widgets receive one of these through {@link Chrome}; none
* of them hold colours of their own.
*
* <p>Colours are packed 0xAARRGGBB, matching {@code RenderBridge}.
*/
@Builder(toBuilder = true)
public record Theme(
int scrim,
int panel,
int panelBorder,
int header,
int surface,
int surfaceHover,
int surfacePressed,
int surfaceSunken,
int accent,
int accentHover,
int accentText,
int text,
int textMuted,
int textFaint,
int success,
int warning,
int danger,
int scrollTrack,
int scrollThumb,
int scrollThumbHover,
int tilePlaceholder,
int overlay,
int padding,
int gap,
int rowHeight,
int controlHeight,
int headerHeight,
int footerHeight,
int tileGap) {
/**
* The one theme PhotoSync ships.
*
* <p>Dark regardless of anything else on screen: these panels sit over a
* rendered world, and a light surface there is a flashbang in a night scene.
*/
public static Theme dark() {
return Theme.builder()
// Not opaque -- the world stays faintly visible behind the panel,
// which is what makes an in-game screen feel like part of the game.
.scrim(0xB8000000)
.panel(0xF01A1A21)
.panelBorder(0xFF3B3B49)
.header(0xFF23232D)
.surface(0xFF272733)
.surfaceHover(0xFF33333F)
.surfacePressed(0xFF3E3E4D)
.surfaceSunken(0xFF15151B)
.accent(0xFF4C8DFF)
.accentHover(0xFF6BA1FF)
.accentText(0xFFFFFFFF)
.text(0xFFE9E9F1)
.textMuted(0xFF9C9CAD)
.textFaint(0xFF63636F)
.success(0xFF5BC98A)
.warning(0xFFE3B44A)
.danger(0xFFE86A6A)
.scrollTrack(0xFF1E1E26)
.scrollThumb(0xFF454556)
.scrollThumbHover(0xFF5C5C71)
.tilePlaceholder(0xFF2A2A35)
.overlay(0xA0000000)
.padding(8)
.gap(4)
.rowHeight(30)
.controlHeight(18)
.headerHeight(28)
.footerHeight(18)
.tileGap(4)
.build();
}
/** The same colour at a different opacity, for hints and disabled states. */
public int fade(int argb, float alpha) {
int scaled = Math.round(((argb >>> 24) & 0xFF) * Math.max(0f, Math.min(1f, alpha)));
return (scaled << 24) | (argb & 0x00FFFFFF);
}
/** Picks the right surface shade for a control's current interaction state. */
public int surfaceFor(boolean hovered, boolean pressed) {
if (pressed) {
return surfacePressed;
}
return hovered ? surfaceHover : surface;
}
}
@@ -0,0 +1,200 @@
package dev.photosync.ui.image;
import dev.photosync.core.provider.RemoteAsset;
import dev.photosync.core.provider.ThumbnailSize;
import dev.photosync.core.thumbnail.ThumbHash;
import dev.photosync.core.thumbnail.ThumbImage;
import dev.photosync.core.thumbnail.ThumbnailLoader;
import dev.photosync.mcapi.render.TextureHandle;
import dev.photosync.mcapi.render.TextureSink;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
/**
* The textures behind the timeline grid: what is on the GPU, what is on its way,
* and what has to go.
*
* <p>An asset shows something on the very first frame it appears, because the
* timeline page already carried a ThumbHash -- twenty-odd bytes that decode to a
* blurred 32x32 -- and swaps in the real thumbnail when it arrives. That is the
* difference between a grid that fills in and a grid of grey boxes.
*
* <p>Everything here runs on the render thread. The fetching does not: this
* class only ever <em>polls</em> {@link ThumbnailLoader}'s futures, so a slow
* server stalls a tile rather than a frame.
*
* <p>Eviction is least-recently-drawn, bounded by the player's cache setting,
* and skips anything drawn in the current frame. Without that last rule a grid
* with more visible tiles than the cache holds would evict a texture it is about
* to draw and re-request it forever.
*/
@Slf4j
public final class ThumbnailCache implements AutoCloseable {
/** What to draw for one asset, and whether it is the real thing yet. */
public record Thumbnail(TextureHandle texture, boolean placeholder) {
}
private final TextureSink textures;
private final ThumbnailLoader loader;
private final ThumbnailSize size;
/** Access-ordered, so its iteration order is the eviction order. */
private final LinkedHashMap<String, Entry> entries = new LinkedHashMap<>(64, 0.75f, true);
private int capacity;
private long frame;
public ThumbnailCache(TextureSink textures, ThumbnailLoader loader, ThumbnailSize size, int capacity) {
this.textures = textures;
this.loader = loader;
this.size = size;
this.capacity = Math.max(8, capacity);
}
/** Follows the player's setting without discarding what is already loaded. */
public void capacity(int value) {
this.capacity = Math.max(8, value);
}
/** Call once at the top of a frame, before any {@link #of} in that frame. */
public void beginFrame() {
frame++;
}
/**
* What to draw for {@code asset} right now, starting a fetch if this is the
* first time it has been asked for.
*
* <p>Empty means there is genuinely nothing yet -- no ThumbHash and no
* thumbnail -- which the grid draws as a plain tile.
*/
public Optional<Thumbnail> of(RemoteAsset asset) {
Entry entry = entries.computeIfAbsent(asset.id(), id -> new Entry(asset));
entry.touched = frame;
entry.poll();
return entry.thumbnail();
}
/** Whether this asset's thumbnail failed outright, so the grid can mark it. */
public boolean failed(String assetId) {
Entry entry = entries.get(assetId);
return entry != null && entry.failed;
}
/** Call at the end of a frame, once every visible tile has been asked for. */
public void endFrame() {
if (entries.size() <= capacity) {
return;
}
Iterator<Map.Entry<String, Entry>> stale = entries.entrySet().iterator();
while (entries.size() > capacity && stale.hasNext()) {
Entry entry = stale.next().getValue();
// Never evict something drawn this frame: on a screen with more
// tiles than the cache holds, that would thrash rather than cache.
if (entry.touched == frame) {
continue;
}
entry.close();
stale.remove();
}
}
/** Drops everything -- when the album changes, or the screen closes. */
public void clear() {
entries.values().forEach(Entry::close);
entries.clear();
}
@Override
public void close() {
clear();
}
// -----------------------------------------------------------------------
private final class Entry implements AutoCloseable {
private final RemoteAsset asset;
private TextureHandle texture;
private boolean real;
private boolean failed;
private CompletableFuture<byte[]> pending;
private long touched;
private Entry(RemoteAsset asset) {
this.asset = asset;
this.texture = decodeHash(asset);
}
private Optional<Thumbnail> thumbnail() {
return texture == null ? Optional.empty() : Optional.of(new Thumbnail(texture, !real));
}
/**
* Advances this entry by whatever is available without blocking: start a
* request, or take delivery of one.
*/
private void poll() {
if (real || failed) {
return;
}
if (pending == null) {
// Empty means the loader is saturated -- a "not now", not a
// failure. Asking again next frame is the retry.
pending = loader.request(asset.id(), size).orElse(null);
return;
}
if (!pending.isDone()) {
return;
}
CompletableFuture<byte[]> finished = pending;
pending = null;
try {
adopt(textures.decode(finished.join()));
real = true;
} catch (IOException | RuntimeException e) {
log.debug("Thumbnail {} is not drawable: {}", asset.id(), e.toString());
failed = true;
}
}
/** Swaps in a new texture and releases whatever it replaces. */
private void adopt(TextureHandle replacement) {
if (texture != null) {
texture.close();
}
texture = replacement;
}
@Override
public void close() {
if (pending != null) {
pending.cancel(false);
pending = null;
}
if (texture != null) {
texture.close();
texture = null;
}
}
private TextureHandle decodeHash(RemoteAsset source) {
return source.thumbHash()
.flatMap(ThumbHash::decode)
.map(ThumbnailCache.this::upload)
.orElse(null);
}
}
private TextureHandle upload(ThumbImage image) {
return textures.upload(image);
}
}
@@ -0,0 +1,104 @@
package dev.photosync.ui.notify;
import dev.photosync.core.config.NotificationKind;
import dev.photosync.core.config.NotificationSettings;
import dev.photosync.mcapi.render.RenderBridge;
import dev.photosync.ui.Chrome;
import dev.photosync.ui.Theme;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Supplier;
/**
* The one-line messages in the bottom-left corner.
*
* <p>Sized to be ignorable. A player who takes a screenshot already knows they
* took one; the message is there to say the upload worked, and it has earned
* about a second and a half of the corner of their eye for that. So: no icon, no
* panel, no sound, no animation beyond a fade -- one line of text that leaves.
*
* <p>{@link #show} is called from upload workers and from the capture thread, so
* the list is concurrent. Everything else happens on the render thread.
*/
public final class Notifications {
/** Above this, the oldest is dropped rather than growing a wall of text. */
private static final int MAX_VISIBLE = 3;
private static final long FADE_MILLIS = 400;
private record Toast(NotificationKind kind, String message, long expiresAt) {
}
private final Chrome chrome;
private final Supplier<NotificationSettings> settings;
private final List<Toast> live = new CopyOnWriteArrayList<>();
public Notifications(Chrome chrome, Supplier<NotificationSettings> settings) {
this.chrome = chrome;
this.settings = settings;
}
/**
* Posts a message, if the player has that kind switched on. Safe to call
* from any thread.
*
* @param message already translated -- the caller knows the arguments
*/
public void show(NotificationKind kind, String message) {
NotificationSettings current = settings.get();
if (!current.shows(kind)) {
return;
}
live.add(new Toast(kind, message, System.currentTimeMillis() + current.lingerMillis()));
while (live.size() > MAX_VISIBLE) {
live.remove(0);
}
}
/** Drops everything on screen -- used when the player turns notifications off. */
public void clear() {
live.clear();
}
/**
* Draws the stack, newest at the bottom.
*
* <p>Anchored to the very bottom of the screen rather than above it: that
* strip is to the left of the hotbar and below the chat, which is the only
* part of the corner that is reliably empty.
*/
public void render(RenderBridge render, long nowMillis) {
live.removeIf(toast -> toast.expiresAt() <= nowMillis);
if (live.isEmpty()) {
return;
}
Theme theme = chrome.theme();
int line = render.lineHeight() + 2;
int y = render.height() - 4 - line;
// Reverse order so the newest sits at the bottom and older ones rise.
for (int i = live.size() - 1; i >= 0; i--) {
Toast toast = live.get(i);
float alpha = fade(toast, nowMillis);
int background = theme.fade(theme.panel(), alpha * 0.85f);
int width = render.textWidth(toast.message()) + 8;
render.fill(4, y, width, line, background);
render.fill(4, y, 1, line, theme.fade(colour(theme, toast.kind()), alpha));
render.text(toast.message(), 8, y + 2, theme.fade(theme.text(), alpha), false);
y -= line + 2;
}
}
private float fade(Toast toast, long nowMillis) {
long remaining = toast.expiresAt() - nowMillis;
return remaining >= FADE_MILLIS ? 1f : Math.max(0f, remaining / (float) FADE_MILLIS);
}
private int colour(Theme theme, NotificationKind kind) {
return switch (kind) {
case CAPTURED -> theme.accent();
case UPLOADED -> theme.success();
case FAILED -> theme.danger();
};
}
}
@@ -0,0 +1,16 @@
/**
* Every screen, widget and pixel PhotoSync draws.
*
* <p>Nothing here imports a Minecraft or Fabric type. The UI talks to the game
* exclusively through {@code :shared:mc-api} -- {@code RenderBridge} for
* drawing, {@code ScreenModel} for lifecycle and input -- which is what lets one
* copy of this code serve nine Minecraft versions.
*
* <p>It follows that this module draws its own widgets rather than using
* vanilla's. That trade is examined in {@code docs/PORTING.md}; the short
* version is that Minecraft's drawing primitives have been stable since 1.20
* while its widget classes have not, so building on the former costs a few
* hundred lines once and the latter would cost an adapter per widget per
* version.
*/
package dev.photosync.ui;
@@ -0,0 +1,269 @@
package dev.photosync.ui.screen;
import dev.photosync.core.provider.Album;
import dev.photosync.core.provider.PhotoProvider;
import dev.photosync.core.provider.ProviderException;
import dev.photosync.mcapi.Keys;
import dev.photosync.mcapi.render.RenderBridge;
import dev.photosync.ui.PhotoSyncUi;
import dev.photosync.ui.Rect;
import dev.photosync.ui.widget.Button;
import dev.photosync.ui.widget.ScrollModel;
import dev.photosync.ui.widget.TextField;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
/**
* Where uploads land: one album, or the whole library.
*
* <p>A separate screen rather than a dropdown in the settings form, because the
* list comes off the network and can be long, empty, or an error -- three states
* a dropdown has nowhere to put. It is reached from the settings screen and
* returns to it, so it wears the settings tab.
*
* <p>Picking writes straight through to the saved config instead of the draft.
* The player did not type this value, they chose it from the server's own list;
* making them press Save afterwards would be asking them to confirm something
* they already confirmed by clicking.
*/
public final class AlbumPickerScreen extends PhotoSyncScreen {
private static final int ROW_HEIGHT = 20;
private final ScrollModel scroll;
private final TextField newAlbumName;
private Rect listArea = Rect.EMPTY;
private Button back;
private Button create;
private volatile List<Album> albums = List.of();
private volatile String error = "";
private volatile boolean loading;
private boolean requested;
public AlbumPickerScreen(PhotoSyncUi ui) {
super(ui);
this.scroll = new ScrollModel(ui.chrome());
this.newAlbumName = new TextField(chrome, ui.bridge().clipboard(), value -> {
});
this.newAlbumName.hint(chrome.translate("photosync.album.new_hint"));
this.newAlbumName.maxLength(64);
}
@Override
protected Tab tab() {
return Tab.SETTINGS;
}
@Override
protected void layoutBody(Rect area) {
int gap = theme().gap();
int control = theme().controlHeight();
Rect actions = area.bottom(control);
Rect creation = area.dropBottom(control + gap).bottom(control);
listArea = area.dropBottom((control + gap) * 2);
scroll.viewport(listArea);
int createWidth = 64;
// The text field is added first so a click on the button, which overlaps
// nothing, still reaches it -- the list hit-tests by rectangle either way.
newAlbumName.bounds(creation.dropRight(createWidth + gap));
widgets.add(newAlbumName);
create = widgets.add(new Button(chrome, chrome.translate("photosync.album.create"), this::createAlbum));
create.bounds(creation.right(createWidth));
back = widgets.add(new Button(chrome, chrome.translate("photosync.album.back"),
() -> ui.open(Tab.SETTINGS)));
back.bounds(actions.left(80));
Button refresh = widgets.add(new Button(chrome, chrome.translate("photosync.album.refresh"), this::load));
refresh.bounds(actions.right(80));
if (!requested) {
requested = true;
load();
}
}
// -----------------------------------------------------------------------
// Talking to the server
// -----------------------------------------------------------------------
private void load() {
Optional<PhotoProvider> provider = ui.core().session().provider();
if (provider.isEmpty()) {
error = chrome.translate("photosync.album.not_configured");
return;
}
loading = true;
error = "";
CompletableFuture.runAsync(() -> {
try {
List<Album> loaded = provider.get().albums();
albums = loaded;
ui.rememberAlbums(loaded);
} catch (ProviderException e) {
error = e.getMessage();
} finally {
loading = false;
}
});
}
private void createAlbum() {
String name = newAlbumName.value().trim();
Optional<PhotoProvider> provider = ui.core().session().provider();
if (name.isEmpty() || provider.isEmpty() || loading) {
return;
}
loading = true;
error = "";
CompletableFuture.runAsync(() -> {
try {
Album created = provider.get().createAlbum(name);
List<Album> merged = new ArrayList<>(albums);
merged.add(created);
albums = List.copyOf(merged);
ui.rememberAlbums(albums);
// Creating an album is only ever a prelude to using it.
ui.game().submit(() -> {
ui.chooseAlbum(created.id());
newAlbumName.reset("");
});
} catch (ProviderException e) {
error = e.getMessage();
} finally {
loading = false;
}
});
}
// -----------------------------------------------------------------------
// Rendering
// -----------------------------------------------------------------------
@Override
protected void renderBody(RenderBridge render, int mouseX, int mouseY) {
scroll.contentHeight((albums.size() + 1) * ROW_HEIGHT);
scroll.advance(System.currentTimeMillis());
create.enabled(!newAlbumName.value().isBlank() && !loading);
chrome.well(render, listArea);
if (!error.isEmpty()) {
chrome.notice(render, listArea, chrome.translate("photosync.album.failed"), error);
return;
}
render.pushClip(listArea.x(), listArea.y(), listArea.width(), listArea.height());
int width = listArea.width() - scroll.gutter();
// Index -1 is the library row, which is always offered and always first:
// it is the one choice that cannot fail to exist.
for (int index = -1; index < albums.size(); index++) {
Rect row = rowBounds(index, width);
if (row.bottom() >= listArea.y() && row.y() <= listArea.bottom()) {
renderRow(render, index, row, mouseX, mouseY);
}
}
render.popClip();
scroll.render(render, mouseX, mouseY);
if (loading) {
chrome.busyBar(render, listArea.bottom(2), System.currentTimeMillis(), theme().accent());
}
}
private void renderRow(RenderBridge render, int index, Rect row, int mouseX, int mouseY) {
boolean library = index < 0;
Album album = library ? null : albums.get(index);
String id = library ? "" : album.id();
boolean selected = ui.draft().albumId().equals(id);
boolean hovered = row.contains(mouseX, mouseY) && listArea.contains(mouseX, mouseY);
if (selected || hovered) {
render.fill(row.x(), row.y(), row.width(), row.height(),
selected ? theme().fade(theme().accent(), 0.30f) : theme().surfaceHover());
}
if (selected) {
render.fill(row.x(), row.y(), 2, row.height(), theme().accent());
}
String name = library ? chrome.translate("photosync.album.library") : album.name();
String count = library ? "" : String.valueOf(album.assetCount());
int countWidth = count.isEmpty() ? 0 : render.textWidth(count) + 8;
chrome.fitted(render, chrome.elide(render, name, row.width() - 10 - countWidth),
row.dropLeft(6).dropRight(countWidth), selected ? theme().text() : theme().textMuted());
if (!count.isEmpty()) {
chrome.fitted(render, count, row.dropRight(6).right(countWidth), theme().textFaint());
}
}
private Rect rowBounds(int index, int width) {
return new Rect(listArea.x(), listArea.y() + (index + 1) * ROW_HEIGHT - scroll.offset(), width, ROW_HEIGHT);
}
@Override
protected String footerText() {
if (loading) {
return chrome.translate("photosync.album.loading");
}
return chrome.translate("photosync.album.status", ui.albumLabel());
}
// -----------------------------------------------------------------------
// Input
// -----------------------------------------------------------------------
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
if (scroll.mouseClicked(mouseX, mouseY, button)) {
return true;
}
if (button == 0 && listArea.contains(mouseX, mouseY) && error.isEmpty()) {
int width = listArea.width() - scroll.gutter();
for (int index = -1; index < albums.size(); index++) {
if (rowBounds(index, width).contains(mouseX, mouseY)) {
ui.chooseAlbum(index < 0 ? "" : albums.get(index).id());
return true;
}
}
}
return super.mouseClicked(mouseX, mouseY, button);
}
@Override
public boolean mouseReleased(double mouseX, double mouseY, int button) {
return scroll.mouseReleased() || super.mouseReleased(mouseX, mouseY, button);
}
@Override
public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
return scroll.mouseDragged(mouseX, mouseY, button)
|| super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY);
}
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double amount) {
return scroll.mouseScrolled(mouseX, mouseY, amount, ROW_HEIGHT)
|| super.mouseScrolled(mouseX, mouseY, amount);
}
/** Enter in the name field creates the album, which is what it looks like it should do. */
@Override
public boolean keyPressed(int key, int scanCode, int modifiers) {
if (newAlbumName.focused() && Keys.confirms(key)) {
createAlbum();
return true;
}
return super.keyPressed(key, scanCode, modifiers);
}
@Override
public boolean closeOnEscape() {
return !widgets.hasFocus();
}
}
@@ -0,0 +1,227 @@
package dev.photosync.ui.screen;
import dev.photosync.mcapi.render.RenderBridge;
import dev.photosync.mcapi.screen.ScreenModel;
import dev.photosync.ui.Chrome;
import dev.photosync.ui.PhotoSyncUi;
import dev.photosync.ui.Rect;
import dev.photosync.ui.Theme;
import dev.photosync.ui.widget.WidgetList;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.experimental.Accessors;
import java.util.ArrayList;
import java.util.List;
/**
* The frame every PhotoSync screen sits in: a centred panel with a title, the
* three tabs, a body and a status line.
*
* <p>Having one frame is what makes the mod feel like one thing. It also means
* the tabs are always in the same place, so "where do I change the album?" has
* the same answer whichever screen the player happens to be looking at -- which
* is the whole of the interaction design brief: set it up once, then only ever
* think about screenshots.
*
* <p>Subclasses fill in the body. They get a {@link WidgetList} that already
* handles focus and mouse capture, and a laid-out rectangle to put things in.
*/
public abstract class PhotoSyncScreen implements ScreenModel {
/** The three things the mod does, in the order a new player meets them. */
@Getter
@Accessors(fluent = true)
@RequiredArgsConstructor
public enum Tab {
QUEUE("photosync.tab.queue"),
BROWSE("photosync.tab.browse"),
SETTINGS("photosync.tab.settings");
private final String titleKey;
}
private static final int TAB_HEIGHT = 16;
private static final int CLOSE_SIZE = 11;
protected final PhotoSyncUi ui;
protected final Chrome chrome;
protected final WidgetList widgets = new WidgetList();
private Rect panel = Rect.EMPTY;
private Rect header = Rect.EMPTY;
private Rect body = Rect.EMPTY;
private Rect footer = Rect.EMPTY;
private Rect closeButton = Rect.EMPTY;
private final List<Rect> tabBounds = new ArrayList<>(Tab.values().length);
protected PhotoSyncScreen(PhotoSyncUi ui) {
this.ui = ui;
this.chrome = ui.chrome();
}
// -----------------------------------------------------------------------
// What subclasses provide
// -----------------------------------------------------------------------
protected abstract Tab tab();
/** Lay out the body. The widget list has already been cleared. */
protected abstract void layoutBody(Rect area);
protected abstract void renderBody(RenderBridge render, int mouseX, int mouseY);
/** The status line along the bottom. Empty for none. */
protected String footerText() {
return "";
}
protected int footerColour() {
return theme().textFaint();
}
// -----------------------------------------------------------------------
// Frame
// -----------------------------------------------------------------------
@Override
public String title() {
return chrome.translate(tab().titleKey());
}
protected Theme theme() {
return chrome.theme();
}
protected Rect body() {
return body;
}
@Override
public final void layout(int width, int height) {
Theme theme = theme();
// Big enough for five columns of thumbnails, capped so it stays a panel
// rather than swallowing the screen on a large display.
int panelWidth = Math.max(220, Math.min(width - 24, 640));
int panelHeight = Math.max(160, Math.min(height - 24, 420));
panel = new Rect((width - panelWidth) / 2, (height - panelHeight) / 2, panelWidth, panelHeight);
Rect inner = panel.inset(theme.padding());
header = inner.top(theme.headerHeight());
closeButton = new Rect(header.right() - CLOSE_SIZE, header.y() + 2, CLOSE_SIZE, CLOSE_SIZE);
Rect tabRow = inner.dropTop(theme.headerHeight()).top(TAB_HEIGHT);
tabBounds.clear();
Tab[] all = Tab.values();
int tabWidth = tabRow.width() / all.length;
for (int i = 0; i < all.length; i++) {
int x = tabRow.x() + i * tabWidth;
int wide = i == all.length - 1 ? tabRow.right() - x : tabWidth;
tabBounds.add(new Rect(x, tabRow.y(), wide, tabRow.height()));
}
Rect below = inner.dropTop(theme.headerHeight() + TAB_HEIGHT + theme.gap());
footer = below.bottom(theme.footerHeight());
body = below.dropBottom(theme.footerHeight() + theme.gap());
widgets.clear();
layoutBody(body);
}
@Override
public final void render(RenderBridge render, int mouseX, int mouseY) {
chrome.scrim(render);
chrome.panel(render, panel);
chrome.fitted(render, title(), header.dropRight(CLOSE_SIZE + 4).withHeight(theme().headerHeight() - 6),
theme().text());
renderClose(render, mouseX, mouseY);
renderTabs(render, mouseX, mouseY);
renderBody(render, mouseX, mouseY);
widgets.render(render, mouseX, mouseY);
String status = footerText();
if (!status.isEmpty()) {
chrome.fitted(render, chrome.elide(render, status, footer.width()), footer, footerColour());
}
}
private void renderClose(RenderBridge render, int mouseX, int mouseY) {
boolean hovered = closeButton.contains(mouseX, mouseY);
int colour = hovered ? theme().danger() : theme().textMuted();
// A cross from two diagonals; there is no line primitive and at eleven
// pixels a glyph would sit off-centre in the box.
for (int i = 2; i < CLOSE_SIZE - 2; i++) {
render.fill(closeButton.x() + i, closeButton.y() + i, 1, 1, colour);
render.fill(closeButton.x() + i, closeButton.bottom() - i - 1, 1, 1, colour);
}
}
private void renderTabs(RenderBridge render, int mouseX, int mouseY) {
Tab[] all = Tab.values();
for (int i = 0; i < all.length; i++) {
Rect bounds = tabBounds.get(i);
boolean selected = all[i] == tab();
boolean hovered = bounds.contains(mouseX, mouseY);
render.fill(bounds.x(), bounds.y(), bounds.width(), bounds.height(),
selected ? theme().surface() : theme().fade(theme().surface(), hovered ? 0.6f : 0.25f));
if (selected) {
render.fill(bounds.x(), bounds.bottom() - 1, bounds.width(), 1, theme().accent());
}
chrome.centered(render, chrome.translate(all[i].titleKey()), bounds,
selected ? theme().text() : theme().textMuted());
}
}
// -----------------------------------------------------------------------
// Input
// -----------------------------------------------------------------------
@Override
public void tick() {
widgets.tick();
}
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
if (button == 0 && closeButton.contains(mouseX, mouseY)) {
ui.close();
return true;
}
if (button == 0) {
for (int i = 0; i < tabBounds.size(); i++) {
if (tabBounds.get(i).contains(mouseX, mouseY)) {
ui.open(Tab.values()[i]);
return true;
}
}
}
return widgets.mouseClicked(mouseX, mouseY, button);
}
@Override
public boolean mouseReleased(double mouseX, double mouseY, int button) {
return widgets.mouseReleased(mouseX, mouseY, button);
}
@Override
public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
return widgets.mouseDragged(mouseX, mouseY, button, deltaX, deltaY);
}
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double amount) {
return widgets.mouseScrolled(mouseX, mouseY, amount);
}
@Override
public boolean keyPressed(int key, int scanCode, int modifiers) {
return widgets.keyPressed(key, scanCode, modifiers);
}
@Override
public boolean charTyped(char character, int modifiers) {
return widgets.charTyped(character, modifiers);
}
}
@@ -0,0 +1,430 @@
package dev.photosync.ui.screen;
import dev.photosync.core.upload.QueuedUpload;
import dev.photosync.core.upload.UploadJob;
import dev.photosync.core.upload.UploadQueue;
import dev.photosync.core.upload.UploadState;
import dev.photosync.mcapi.render.RenderBridge;
import dev.photosync.mcapi.render.TextureHandle;
import dev.photosync.ui.PhotoSyncUi;
import dev.photosync.ui.Rect;
import dev.photosync.ui.widget.Button;
import dev.photosync.ui.widget.ScrollModel;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.function.Consumer;
/**
* The screenshots this game knows about and what has happened to them.
*
* <p>This is the screen the mod is for. Everything else is setup; this is where
* a player who has just pressed F2 looks to see that it went somewhere.
*
* <p>Rows are deliberately plain -- a name, a state, a bar. The preview lives in
* a single pane on the right, decoded one at a time, because a screenshot is a
* full-resolution PNG and a column of them would be tens of megabytes of texture
* for a list the player scrolls past in two seconds.
*/
@Slf4j
public final class QueueScreen extends PhotoSyncScreen {
private static final int DETAIL_WIDTH = 140;
private static final int MIN_WIDTH_FOR_DETAIL = 340;
private final UploadQueue queue;
private final ScrollModel scroll;
private final Preview preview = new Preview();
private Rect listArea = Rect.EMPTY;
private Rect detailArea = Rect.EMPTY;
private String selectedId;
private List<QueuedUpload> rows = List.of();
private Button retryAll;
private Button clearFinished;
private Button retryOne;
private Button cancelOne;
private Button revealOne;
private Button forgetOne;
public QueueScreen(PhotoSyncUi ui) {
super(ui);
this.queue = ui.core().queue();
this.scroll = new ScrollModel(ui.chrome());
}
@Override
protected Tab tab() {
return Tab.QUEUE;
}
// -----------------------------------------------------------------------
// Layout
// -----------------------------------------------------------------------
@Override
protected void layoutBody(Rect area) {
int gap = theme().gap();
Rect actions = area.bottom(theme().controlHeight());
Rect content = area.dropBottom(theme().controlHeight() + gap);
boolean roomForDetail = content.width() >= MIN_WIDTH_FOR_DETAIL;
detailArea = roomForDetail ? content.right(DETAIL_WIDTH) : Rect.EMPTY;
listArea = roomForDetail ? content.dropRight(DETAIL_WIDTH + gap) : content;
scroll.viewport(listArea);
retryAll = widgets.add(new Button(chrome, chrome.translate("photosync.queue.retry_all"),
queue::retryAllFailed));
retryAll.bounds(actions.left(actions.width() / 2 - gap / 2));
clearFinished = widgets.add(new Button(chrome, chrome.translate("photosync.queue.clear_finished"),
queue::clearFinished));
clearFinished.bounds(actions.right(actions.width() / 2 - gap / 2));
layoutDetailButtons();
}
private void layoutDetailButtons() {
if (detailArea.isEmpty()) {
retryOne = null;
cancelOne = null;
revealOne = null;
forgetOne = null;
return;
}
int gap = theme().gap();
int height = theme().controlHeight();
int half = (detailArea.width() - 8 - gap) / 2;
Rect column = detailArea.inset(4);
Rect first = new Rect(column.x(), column.bottom() - height * 2 - gap, column.width(), height);
Rect second = new Rect(column.x(), column.bottom() - height, column.width(), height);
retryOne = widgets.add(new Button(chrome, chrome.translate("photosync.queue.retry"),
() -> withSelection(job -> queue.retry(job.id()))));
retryOne.bounds(first.left(half));
cancelOne = widgets.add(new Button(chrome, chrome.translate("photosync.queue.cancel"),
() -> withSelection(job -> queue.cancel(job.id()))));
cancelOne.emphasized(Button.Emphasis.DANGER).bounds(first.right(half));
revealOne = widgets.add(new Button(chrome, chrome.translate("photosync.queue.reveal"),
() -> withSelection(job -> ui.game().reveal(job.path()))));
revealOne.bounds(second.left(half));
forgetOne = widgets.add(new Button(chrome, chrome.translate("photosync.queue.forget"),
() -> withSelection(job -> {
queue.forget(job.id());
select(null);
})));
forgetOne.bounds(second.right(half));
}
/** The detail buttons all act on whatever is selected, or on nothing at all. */
private void withSelection(Consumer<UploadJob> action) {
selected().map(QueuedUpload::job).ifPresent(action);
}
// -----------------------------------------------------------------------
// Rendering
// -----------------------------------------------------------------------
@Override
protected void renderBody(RenderBridge render, int mouseX, int mouseY) {
// Newest first, matching the browser -- the screenshot you just took is
// the one you are looking for. (Not List::reversed: shared code targets
// Java 17, because 1.20 through 1.20.4 run on a Java 17 JVM.)
List<QueuedUpload> ordered = new ArrayList<>(queue.snapshot());
Collections.reverse(ordered);
rows = ordered;
scroll.contentHeight(rows.size() * theme().rowHeight());
scroll.advance(System.currentTimeMillis());
updateButtons();
chrome.well(render, listArea);
if (rows.isEmpty()) {
chrome.notice(render, listArea, chrome.translate("photosync.queue.empty"),
chrome.translate("photosync.queue.empty.hint"));
} else {
renderRows(render, mouseX, mouseY);
}
scroll.render(render, mouseX, mouseY);
if (!detailArea.isEmpty()) {
renderDetail(render);
}
}
private void renderRows(RenderBridge render, int mouseX, int mouseY) {
int rowHeight = theme().rowHeight();
int gutter = scroll.gutter();
render.pushClip(listArea.x(), listArea.y(), listArea.width(), listArea.height());
int first = Math.max(0, scroll.offset() / rowHeight);
int last = Math.min(rows.size(), (scroll.offset() + listArea.height()) / rowHeight + 1);
for (int i = first; i < last; i++) {
Rect bounds = new Rect(listArea.x(), listArea.y() + i * rowHeight - scroll.offset(),
listArea.width() - gutter, rowHeight);
renderRow(render, rows.get(i), bounds, bounds.contains(mouseX, mouseY));
}
render.popClip();
}
private void renderRow(RenderBridge render, QueuedUpload row, Rect bounds, boolean hovered) {
UploadJob job = row.job();
boolean selected = job.id().equals(selectedId);
if (selected || hovered) {
render.fill(bounds.x(), bounds.y(), bounds.width(), bounds.height(),
selected ? theme().surface() : theme().surfaceHover());
}
if (selected) {
render.fill(bounds.x(), bounds.y(), 1, bounds.height(), theme().accent());
}
Rect inner = bounds.inset(5, 3, 5, 3);
String badge = chrome.translate(stateKey(job.state()));
int badgeWidth = render.textWidth(badge) + 6;
chrome.badge(render, badge, inner.right() - badgeWidth, inner.y(),
theme().fade(stateColour(job.state()), 0.25f), stateColour(job.state()));
render.text(chrome.elide(render, job.fileName(), inner.width() - badgeWidth - 6),
inner.x(), inner.y() + 2, theme().text(), false);
Rect secondLine = new Rect(inner.x(), inner.y() + render.lineHeight() + 3, inner.width(), 4);
switch (job.state()) {
case UPLOADING -> chrome.progressBar(render, secondLine, row.fraction(), theme().accent());
case PENDING -> chrome.progressBar(render, secondLine, 0, theme().accent());
case RETRYING -> chrome.busyBar(render, secondLine, System.currentTimeMillis(), theme().warning());
default -> renderRowMessage(render, job, secondLine);
}
}
private void renderRowMessage(RenderBridge render, UploadJob job, Rect line) {
String message = job.failureMessage().orElseGet(() -> formatSize(job.sizeBytes()));
int colour = job.state() == UploadState.FAILED ? theme().danger() : theme().textFaint();
render.text(chrome.elide(render, message, line.width()), line.x(), line.y() - 2, colour, false);
}
private void renderDetail(RenderBridge render) {
chrome.well(render, detailArea);
Optional<QueuedUpload> selection = selected();
if (selection.isEmpty()) {
chrome.notice(render, detailArea, chrome.translate("photosync.queue.no_selection"), "");
return;
}
UploadJob job = selection.get().job();
preview.follow(job.path());
Rect inner = detailArea.inset(4);
int line = render.lineHeight() + 2;
// Square, but never so tall that it pushes the buttons off the panel.
int side = Math.max(24, Math.min(inner.width(),
inner.height() - theme().controlHeight() * 2 - theme().gap() - line * 5));
Rect image = new Rect(inner.x(), inner.y(), inner.width(), side);
render.fill(image.x(), image.y(), image.width(), image.height(), theme().tilePlaceholder());
preview.texture().ifPresentOrElse(
texture -> drawFitted(render, texture, image),
() -> chrome.notice(render, image, chrome.translate(preview.failed()
? "photosync.queue.preview_failed"
: "photosync.queue.preview_loading"), ""));
int y = image.bottom() + 4;
render.text(chrome.elide(render, job.fileName(), inner.width()), inner.x(), y, theme().text(), false);
render.text(formatSize(job.sizeBytes()), inner.x(), y + line, theme().textFaint(), false);
job.failureMessage().ifPresent(message -> {
List<String> lines = chrome.wrap(render, message, inner.width(), 3);
for (int i = 0; i < lines.size(); i++) {
render.text(lines.get(i), inner.x(), y + line * (i + 2), theme().danger(), false);
}
});
}
/** Letterboxes rather than stretches: a squashed screenshot is worse than a border. */
private void drawFitted(RenderBridge render, TextureHandle texture, Rect area) {
double scale = Math.min(area.width() / (double) texture.width(), area.height() / (double) texture.height());
int width = Math.max(1, (int) Math.round(texture.width() * scale));
int height = Math.max(1, (int) Math.round(texture.height() * scale));
render.image(texture, area.centerX() - width / 2, area.centerY() - height / 2, width, height);
}
private void updateButtons() {
Optional<UploadState> state = selected().map(row -> row.job().state());
retryAll.enabled(queue.failedCount() > 0);
clearFinished.enabled(rows.stream().anyMatch(row -> row.job().state().isFinished()));
if (retryOne == null) {
return;
}
retryOne.enabled(state.filter(value -> value == UploadState.FAILED).isPresent());
cancelOne.enabled(state.filter(UploadState::isActive).isPresent());
revealOne.enabled(state.isPresent());
forgetOne.enabled(state.filter(value -> !value.isActive()).isPresent());
}
@Override
protected String footerText() {
int active = queue.activeCount();
int failed = queue.failedCount();
if (active == 0 && failed == 0) {
return chrome.translate("photosync.queue.idle");
}
return chrome.translate("photosync.queue.status", active, failed);
}
// -----------------------------------------------------------------------
// Input
// -----------------------------------------------------------------------
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
if (scroll.mouseClicked(mouseX, mouseY, button)) {
return true;
}
if (button == 0 && listArea.contains(mouseX, mouseY)) {
int index = (int) ((mouseY - listArea.y() + scroll.offset()) / theme().rowHeight());
select(index >= 0 && index < rows.size() ? rows.get(index).job().id() : null);
return true;
}
return super.mouseClicked(mouseX, mouseY, button);
}
@Override
public boolean mouseReleased(double mouseX, double mouseY, int button) {
return scroll.mouseReleased() || super.mouseReleased(mouseX, mouseY, button);
}
@Override
public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
return scroll.mouseDragged(mouseX, mouseY, button)
|| super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY);
}
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double amount) {
return scroll.mouseScrolled(mouseX, mouseY, amount, theme().rowHeight())
|| super.mouseScrolled(mouseX, mouseY, amount);
}
@Override
public void closed() {
preview.close();
}
private Optional<QueuedUpload> selected() {
return rows.stream().filter(row -> row.job().id().equals(selectedId)).findFirst();
}
private void select(String jobId) {
if (!Objects.equals(selectedId, jobId)) {
selectedId = jobId;
preview.reset();
}
}
private String stateKey(UploadState state) {
return "photosync.state." + state.name().toLowerCase(Locale.ROOT);
}
private int stateColour(UploadState state) {
return switch (state) {
case COMPLETED -> theme().success();
case FAILED -> theme().danger();
case RETRYING -> theme().warning();
case CANCELLED -> theme().textFaint();
case PENDING, UPLOADING -> theme().accent();
};
}
private String formatSize(long bytes) {
if (bytes < 1024) {
return bytes + " B";
}
if (bytes < 1024 * 1024) {
return String.format(Locale.ROOT, "%.1f KB", bytes / 1024.0);
}
return String.format(Locale.ROOT, "%.1f MB", bytes / (1024.0 * 1024.0));
}
/**
* The one decoded screenshot on screen.
*
* <p>The file is read on a worker and decoded on the render thread, because
* only the render thread may make a texture and only a worker should touch a
* disk. Exactly one texture is alive at a time.
*/
private final class Preview implements AutoCloseable {
private Path source;
private CompletableFuture<byte[]> reading;
private TextureHandle texture;
private boolean failed;
/** Called every frame with the selected file; only acts when it changes. */
private void follow(Path path) {
if (!path.equals(source)) {
close();
source = path;
reading = CompletableFuture.supplyAsync(() -> readAll(path));
}
poll();
}
private void poll() {
if (reading == null || !reading.isDone()) {
return;
}
CompletableFuture<byte[]> finished = reading;
reading = null;
try {
texture = ui.bridge().textures().decode(finished.join());
} catch (IOException | RuntimeException e) {
log.debug("Cannot preview {}", source, e);
failed = true;
}
}
private Optional<TextureHandle> texture() {
return Optional.ofNullable(texture);
}
private boolean failed() {
return failed;
}
/** Forgets the current image so the next {@link #follow} reloads. */
private void reset() {
close();
source = null;
}
@Override
public void close() {
if (reading != null) {
reading.cancel(false);
reading = null;
}
if (texture != null) {
texture.close();
texture = null;
}
failed = false;
}
private byte[] readAll(Path path) {
try {
return Files.readAllBytes(path);
} catch (IOException e) {
throw new CompletionException(e);
}
}
}
}
@@ -0,0 +1,152 @@
package dev.photosync.ui.screen;
import dev.photosync.core.upload.QueuedUpload;
import dev.photosync.mcapi.render.RenderBridge;
import dev.photosync.mcapi.screen.ScreenModel;
import dev.photosync.ui.Chrome;
import dev.photosync.ui.PhotoSyncUi;
import dev.photosync.ui.Rect;
import dev.photosync.ui.Theme;
import dev.photosync.ui.widget.Button;
import dev.photosync.ui.widget.WidgetList;
import java.util.List;
/**
* The screen that stands between an in-flight upload and a closed game.
*
* <p>Quitting mid-upload is the one moment where the mod has to interrupt the
* player, so it is worth being exact about what it offers. Waiting is the
* default and needs no click: the dialog watches the queue and lets the game go
* as soon as it empties. "Quit anyway" is always available and never destroys
* anything -- the queue is on disk and resumes next launch -- so the button says
* what happens next rather than warning about consequences that do not exist.
*
* <p>Escape does nothing here. It is the one modal in the mod: the game is
* already on its way out, and a dialog that can be dismissed without answering
* would leave the player in a session they have asked to end.
*/
public final class QuitDialog implements ScreenModel {
private static final int PANEL_WIDTH = 260;
private static final int PANEL_HEIGHT = 116;
private final PhotoSyncUi ui;
private final Chrome chrome;
private final WidgetList widgets = new WidgetList();
/** How many were outstanding when the dialog opened, so progress has a denominator. */
private final int initialCount;
private Rect panel = Rect.EMPTY;
private Rect body = Rect.EMPTY;
private boolean released;
public QuitDialog(PhotoSyncUi ui) {
this.ui = ui;
this.chrome = ui.chrome();
this.initialCount = Math.max(1, ui.core().queue().activeCount());
}
private Theme theme() {
return chrome.theme();
}
@Override
public String title() {
return chrome.translate("photosync.quit.title");
}
@Override
public void layout(int width, int height) {
panel = new Rect((width - PANEL_WIDTH) / 2, (height - PANEL_HEIGHT) / 2, PANEL_WIDTH, PANEL_HEIGHT);
body = panel.inset(theme().padding());
widgets.clear();
Rect actions = body.bottom(theme().controlHeight());
int half = (actions.width() - theme().gap()) / 2;
Button stay = widgets.add(new Button(chrome, chrome.translate("photosync.quit.keep_playing"), this::dismiss));
stay.bounds(actions.left(half));
Button quit = widgets.add(new Button(chrome, chrome.translate("photosync.quit.anyway"), this::release));
quit.emphasized(Button.Emphasis.DANGER).bounds(actions.right(half));
}
@Override
public void render(RenderBridge render, int mouseX, int mouseY) {
chrome.scrim(render);
chrome.panel(render, panel);
int line = render.lineHeight();
int y = body.y();
chrome.label(render, title(), body.x(), y, theme().text());
List<QueuedUpload> active = ui.core().queue().snapshot().stream()
.filter(entry -> entry.job().state().isActive())
.toList();
y += line + 6;
chrome.label(render, chrome.translate("photosync.quit.remaining", active.size()),
body.x(), y, theme().textMuted());
y += line + 4;
Rect bar = new Rect(body.x(), y, body.width(), 4);
// Overall progress, measured in files rather than bytes: the count is the
// number the player was just shown, and mixing the two units would make
// the bar disagree with the line above it.
double done = (initialCount - active.size()) / (double) initialCount;
chrome.progressBar(render, bar, done, theme().accent());
y += 10;
String current = active.isEmpty()
? chrome.translate("photosync.quit.finishing")
: chrome.elide(render, active.get(0).job().fileName(), body.width());
chrome.label(render, current, body.x(), y, theme().textFaint());
y += line + 2;
chrome.label(render, chrome.translate("photosync.quit.hint"), body.x(), y, theme().textFaint());
widgets.render(render, mouseX, mouseY);
}
/**
* Polled rather than driven by an upload event, because the interesting
* transition is "the queue became empty" and a tick is the cheapest place to
* notice it that is already on the render thread.
*/
@Override
public void tick() {
widgets.tick();
if (!ui.core().isBusy()) {
release();
}
}
/** Lets the shutdown proceed, exactly once. */
private void release() {
if (released) {
return;
}
released = true;
ui.quitNow();
}
private void dismiss() {
ui.close();
}
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
return widgets.mouseClicked(mouseX, mouseY, button);
}
@Override
public boolean mouseReleased(double mouseX, double mouseY, int button) {
return widgets.mouseReleased(mouseX, mouseY, button);
}
@Override
public boolean closeOnEscape() {
return false;
}
}
@@ -0,0 +1,506 @@
package dev.photosync.ui.screen;
import dev.photosync.core.config.AutoCaptureSettings;
import dev.photosync.core.config.BrowserSettings;
import dev.photosync.core.config.NotificationSettings;
import dev.photosync.core.config.PhotoSyncConfig;
import dev.photosync.core.config.UploadSettings;
import dev.photosync.core.provider.PhotoProvider;
import dev.photosync.core.provider.ProviderConnection;
import dev.photosync.core.provider.ProviderDescriptor;
import dev.photosync.core.provider.ProviderException;
import dev.photosync.core.provider.ProviderId;
import dev.photosync.core.provider.ProviderIdentity;
import dev.photosync.mcapi.render.RenderBridge;
import dev.photosync.ui.PhotoSyncUi;
import dev.photosync.ui.Rect;
import dev.photosync.ui.widget.Button;
import dev.photosync.ui.widget.ScrollModel;
import dev.photosync.ui.widget.Slider;
import dev.photosync.ui.widget.TextField;
import dev.photosync.ui.widget.Toggle;
import dev.photosync.ui.widget.Widget;
import dev.photosync.ui.widget.WidgetList;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.CompletableFuture;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer;
import java.util.function.UnaryOperator;
/**
* Everything the player configures, in one scrolling column.
*
* <p>Edits go into a draft rather than straight into the live config, for two
* reasons. Typing a server URL a character at a time would otherwise rebuild the
* HTTP client on every keystroke; and "test connection" has to be able to try
* credentials that have not been committed yet. The draft is applied when the
* player leaves the screen, which is stated in the footer -- there is no way to
* make an edit and quietly lose it.
*
* <p>The controls read and write the draft through lambdas instead of holding
* their own copies, so the draft stays the single source of truth even when
* something else -- reverting, or picking an album on another screen -- changes
* it underneath them.
*/
public final class SettingsScreen extends PhotoSyncScreen {
/** One line of the form. A null widget makes it a section heading. */
private static final class Row {
private final String labelKey;
private final Widget widget;
private final boolean fullWidth;
private int top;
private int height;
private Row(String labelKey, Widget widget, boolean fullWidth) {
this.labelKey = labelKey;
this.widget = widget;
this.fullWidth = fullWidth;
}
private boolean isHeading() {
return widget == null;
}
}
private final ScrollModel scroll;
private final WidgetList content = new WidgetList();
private final List<Row> rows = new ArrayList<>();
private Rect viewport = Rect.EMPTY;
private int measuredLineHeight = -1;
private Button save;
private Button revert;
private Button test;
private volatile String testStatus = "";
public SettingsScreen(PhotoSyncUi ui) {
super(ui);
this.scroll = new ScrollModel(ui.chrome());
}
@Override
protected Tab tab() {
return Tab.SETTINGS;
}
private PhotoSyncConfig draft() {
return ui.draft();
}
// -----------------------------------------------------------------------
// Building the form
// -----------------------------------------------------------------------
@Override
protected void layoutBody(Rect area) {
int gap = theme().gap();
Rect actions = area.bottom(theme().controlHeight());
viewport = area.dropBottom(theme().controlHeight() + gap);
scroll.viewport(viewport);
int half = (actions.width() - gap) / 2;
save = widgets.add(new Button(chrome, chrome.translate("photosync.settings.save"), ui::applyDraft));
save.emphasized(Button.Emphasis.PRIMARY).bounds(actions.left(half));
revert = widgets.add(new Button(chrome, chrome.translate("photosync.settings.revert"), () -> {
ui.resetDraft();
rebuild();
}));
revert.bounds(actions.right(half));
rebuild();
}
/** Rebuilds every control from scratch, which is also how "revert" takes effect. */
private void rebuild() {
content.clear();
rows.clear();
measuredLineHeight = -1;
buildConnection();
buildUpload();
buildAutoCapture();
buildNotifications();
buildBrowser();
}
private void buildConnection() {
heading("photosync.settings.section.connection");
List<ProviderDescriptor> providers = ui.core().catalog().descriptors();
ProviderDescriptor descriptor = ui.core().catalog().describe(draft().provider()).orElse(providers.get(0));
if (providers.size() > 1) {
// Only worth a control when there is something to choose between.
control("photosync.settings.provider", new Button(chrome, chrome.translate(descriptor.nameKey()),
this::cycleProvider));
}
TextField endpoint = new TextField(chrome, ui.bridge().clipboard(),
value -> edit(config -> config.toBuilder()
.connection(new ProviderConnection(value, config.connection().secret()))
.build()));
endpoint.reset(draft().connection().endpoint());
endpoint.hint(chrome.translate(descriptor.endpointHintKey()));
control(descriptor.endpointKey(), endpoint);
TextField secret = new TextField(chrome, ui.bridge().clipboard(),
value -> edit(config -> config.toBuilder()
.connection(new ProviderConnection(config.connection().endpoint(), value))
.build()));
secret.reset(draft().connection().secret());
secret.hint(chrome.translate(descriptor.secretHintKey()));
secret.masked(true);
control(descriptor.secretKey(), secret);
test = new Button(chrome, chrome.translate("photosync.settings.test"), this::testConnection);
control("photosync.settings.connection_state", test);
if (descriptor.supportsAlbums()) {
control("photosync.settings.album",
new Button(chrome, ui.albumLabel(), () -> ui.openAlbumPicker()));
}
}
private void buildUpload() {
heading("photosync.settings.section.upload");
toggle("photosync.settings.upload_on_capture", "photosync.settings.upload_on_capture.detail",
() -> draft().upload().uploadOnCapture(),
value -> upload(settings -> settings.toBuilder().uploadOnCapture(value).build()));
control("photosync.settings.concurrency", new Slider(chrome,
() -> draft().upload().concurrency(),
value -> upload(settings -> settings.toBuilder().concurrency(value).build()),
1, UploadSettings.MAX_CONCURRENCY, String::valueOf));
control("photosync.settings.attempts", new Slider(chrome,
() -> draft().upload().maxAttempts(),
value -> upload(settings -> settings.toBuilder().maxAttempts(value).build()),
1, 20, String::valueOf));
control("photosync.settings.backoff", new Slider(chrome,
() -> draft().upload().retryBackoffSeconds(),
value -> upload(settings -> settings.toBuilder().retryBackoffSeconds(value).build()),
1, 300, this::formatSeconds));
toggle("photosync.settings.wait_on_quit", "photosync.settings.wait_on_quit.detail",
() -> draft().upload().waitOnQuit(),
value -> upload(settings -> settings.toBuilder().waitOnQuit(value).build()));
toggle("photosync.settings.delete_local", "photosync.settings.delete_local.detail",
() -> draft().upload().deleteLocalAfterUpload(),
value -> upload(settings -> settings.toBuilder().deleteLocalAfterUpload(value).build()));
}
private void buildAutoCapture() {
heading("photosync.settings.section.auto_capture");
toggle("photosync.settings.auto_capture", "photosync.settings.auto_capture.detail",
() -> draft().autoCapture().enabled(),
value -> autoCapture(settings -> settings.toBuilder().enabled(value).build()));
control("photosync.settings.interval", new Slider(chrome,
() -> draft().autoCapture().intervalSeconds(),
value -> autoCapture(settings -> settings.toBuilder().intervalSeconds(value).build()),
AutoCaptureSettings.MIN_INTERVAL_SECONDS, AutoCaptureSettings.MAX_INTERVAL_SECONDS,
this::formatSeconds));
TextField suffix = new TextField(chrome, ui.bridge().clipboard(),
value -> autoCapture(settings -> settings.toBuilder().fileNameSuffix(value).build()));
suffix.reset(draft().autoCapture().fileNameSuffix());
suffix.hint("_auto");
suffix.maxLength(24);
control("photosync.settings.suffix", suffix);
toggle("photosync.settings.only_in_world", "",
() -> draft().autoCapture().onlyInWorld(),
value -> autoCapture(settings -> settings.toBuilder().onlyInWorld(value).build()));
toggle("photosync.settings.skip_when_screen_open", "",
() -> draft().autoCapture().skipWhenScreenOpen(),
value -> autoCapture(settings -> settings.toBuilder().skipWhenScreenOpen(value).build()));
}
private void buildNotifications() {
heading("photosync.settings.section.notifications");
toggle("photosync.settings.notify", "photosync.settings.notify.detail",
() -> draft().notifications().enabled(),
value -> notifications(settings -> settings.toBuilder().enabled(value).build()));
toggle("photosync.settings.notify_capture", "",
() -> draft().notifications().onCapture(),
value -> notifications(settings -> settings.toBuilder().onCapture(value).build()));
toggle("photosync.settings.notify_uploaded", "",
() -> draft().notifications().onUploaded(),
value -> notifications(settings -> settings.toBuilder().onUploaded(value).build()));
toggle("photosync.settings.notify_failed", "",
() -> draft().notifications().onFailed(),
value -> notifications(settings -> settings.toBuilder().onFailed(value).build()));
control("photosync.settings.linger", new Slider(chrome,
() -> draft().notifications().lingerMillis(),
value -> notifications(settings -> settings.toBuilder().lingerMillis(value).build()),
500, 15_000, millis -> String.format(Locale.ROOT, "%.1fs", millis / 1000.0)));
}
private void buildBrowser() {
heading("photosync.settings.section.browser");
control("photosync.settings.tile_size", new Slider(chrome,
() -> draft().browser().tileSize(),
value -> browser(settings -> settings.toBuilder().tileSize(value).build()),
48, 192, value -> value + " px"));
control("photosync.settings.cache", new Slider(chrome,
() -> draft().browser().thumbnailCacheEntries(),
value -> browser(settings -> settings.toBuilder().thumbnailCacheEntries(value).build()),
32, 2048, String::valueOf));
toggle("photosync.settings.video_badge", "",
() -> draft().browser().showVideoBadge(),
value -> browser(settings -> settings.toBuilder().showVideoBadge(value).build()));
}
// -----------------------------------------------------------------------
// Row helpers
// -----------------------------------------------------------------------
private void heading(String key) {
rows.add(new Row(key, null, true));
}
private void control(String labelKey, Widget widget) {
rows.add(new Row(labelKey, content.add(widget), false));
}
private void toggle(String labelKey, String detailKey, BooleanSupplier reader, Consumer<Boolean> writer) {
Toggle widget = new Toggle(chrome, chrome.translate(labelKey), reader, writer);
if (!detailKey.isEmpty()) {
widget.describedAs(chrome.translate(detailKey));
}
rows.add(new Row(labelKey, content.add(widget), true));
}
// -----------------------------------------------------------------------
// Draft edits
// -----------------------------------------------------------------------
private void edit(UnaryOperator<PhotoSyncConfig> change) {
ui.draft(change.apply(draft()));
}
private void upload(UnaryOperator<UploadSettings> change) {
edit(config -> config.toBuilder().upload(change.apply(config.upload())).build());
}
private void autoCapture(UnaryOperator<AutoCaptureSettings> change) {
edit(config -> config.toBuilder().autoCapture(change.apply(config.autoCapture())).build());
}
private void notifications(UnaryOperator<NotificationSettings> change) {
edit(config -> config.toBuilder().notifications(change.apply(config.notifications())).build());
}
private void browser(UnaryOperator<BrowserSettings> change) {
edit(config -> config.toBuilder().browser(change.apply(config.browser())).build());
}
private void cycleProvider() {
List<ProviderDescriptor> providers = ui.core().catalog().descriptors();
ProviderId current = draft().provider();
int index = 0;
for (int i = 0; i < providers.size(); i++) {
if (providers.get(i).id().equals(current)) {
index = i;
}
}
ProviderId next = providers.get((index + 1) % providers.size()).id();
edit(config -> config.toBuilder().provider(next).build());
// The next provider labels its credentials differently, so the whole
// connection section has to be built again.
rebuild();
}
/**
* Tries the draft's credentials without committing them, on a worker so a
* server that is down does not freeze the screen.
*/
private void testConnection() {
ProviderId id = draft().provider();
ProviderConnection connection = draft().connection();
if (!connection.isConfigured()) {
testStatus = chrome.translate("photosync.settings.test.incomplete");
return;
}
testStatus = chrome.translate("photosync.settings.test.running");
test.enabled(false);
CompletableFuture.runAsync(() -> {
try (PhotoProvider probe = ui.core().session().probe(id, connection)) {
ProviderIdentity identity = probe.identify();
testStatus = chrome.translate("photosync.settings.test.ok",
identity.accountName(), identity.serverVersion());
} catch (ProviderException e) {
testStatus = chrome.translate("photosync.settings.test.failed", e.getMessage());
} catch (RuntimeException e) {
testStatus = chrome.translate("photosync.settings.test.failed", e.toString());
}
}).whenComplete((ignored, failure) -> ui.game().submit(() -> test.enabled(true)));
}
// -----------------------------------------------------------------------
// Rendering
// -----------------------------------------------------------------------
@Override
protected void renderBody(RenderBridge render, int mouseX, int mouseY) {
measure(render);
scroll.advance(System.currentTimeMillis());
save.enabled(ui.draftIsDirty());
revert.enabled(ui.draftIsDirty());
chrome.well(render, viewport);
int labelWidth = Math.min(130, viewport.width() * 45 / 100);
int usable = viewport.width() - scroll.gutter();
render.pushClip(viewport.x(), viewport.y(), viewport.width(), viewport.height());
for (Row row : rows) {
int y = viewport.y() + row.top - scroll.offset();
boolean onScreen = y + row.height >= viewport.y() && y <= viewport.bottom();
if (row.isHeading()) {
if (onScreen) {
renderHeading(render, row, y, usable);
}
continue;
}
row.widget.visible(onScreen);
if (!onScreen) {
continue;
}
if (row.fullWidth) {
row.widget.bounds(new Rect(viewport.x() + 6, y + 2, usable - 12, row.height - 4));
} else {
chrome.fitted(render, chrome.translate(row.labelKey),
new Rect(viewport.x() + 6, y, labelWidth - 8, row.height), theme().textMuted());
row.widget.bounds(new Rect(viewport.x() + labelWidth, y + 3,
usable - labelWidth - 6, theme().controlHeight()));
}
}
content.render(render, mouseX, mouseY);
render.popClip();
scroll.render(render, mouseX, mouseY);
}
private void renderHeading(RenderBridge render, Row row, int y, int usable) {
int baseline = y + row.height - render.lineHeight() - 3;
chrome.label(render, chrome.translate(row.labelKey), viewport.x() + 4, baseline, theme().accent());
chrome.divider(render, viewport.x() + 4, y + row.height - 2, usable - 8);
}
/**
* Heights depend on the font, which is only knowable at render time -- a
* toggle with a description is two lines tall and one without is one.
*/
private void measure(RenderBridge render) {
if (measuredLineHeight == render.lineHeight()) {
return;
}
measuredLineHeight = render.lineHeight();
int y = 0;
for (Row row : rows) {
row.height = heightOf(row, render);
row.top = y;
y += row.height;
}
scroll.contentHeight(y);
}
private int heightOf(Row row, RenderBridge render) {
if (row.isHeading()) {
return render.lineHeight() + 12;
}
if (row.widget instanceof Toggle toggle) {
return toggle.preferredHeight(render) + 4;
}
return theme().controlHeight() + 6;
}
@Override
protected String footerText() {
if (!testStatus.isEmpty()) {
return testStatus;
}
return chrome.translate(ui.draftIsDirty()
? "photosync.settings.dirty"
: "photosync.settings.clean");
}
@Override
protected int footerColour() {
return ui.draftIsDirty() && testStatus.isEmpty() ? theme().warning() : theme().textFaint();
}
private String formatSeconds(int seconds) {
if (seconds < 60) {
return seconds + "s";
}
return seconds % 60 == 0
? seconds / 60 + "m"
: String.format(Locale.ROOT, "%dm %ds", seconds / 60, seconds % 60);
}
// -----------------------------------------------------------------------
// Input
// -----------------------------------------------------------------------
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
if (scroll.mouseClicked(mouseX, mouseY, button)) {
return true;
}
if (viewport.contains(mouseX, mouseY)) {
return content.mouseClicked(mouseX, mouseY, button);
}
content.focus(null);
return super.mouseClicked(mouseX, mouseY, button);
}
@Override
public boolean mouseReleased(double mouseX, double mouseY, int button) {
boolean handled = scroll.mouseReleased();
handled |= content.mouseReleased(mouseX, mouseY, button);
return handled || super.mouseReleased(mouseX, mouseY, button);
}
@Override
public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
if (scroll.mouseDragged(mouseX, mouseY, button)
|| content.mouseDragged(mouseX, mouseY, button, deltaX, deltaY)) {
return true;
}
return super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY);
}
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double amount) {
// The control under the cursor gets first refusal, so a slider can be
// nudged by the wheel without the page moving underneath it.
if (viewport.contains(mouseX, mouseY) && content.mouseScrolled(mouseX, mouseY, amount)) {
return true;
}
return scroll.mouseScrolled(mouseX, mouseY, amount, theme().rowHeight())
|| super.mouseScrolled(mouseX, mouseY, amount);
}
@Override
public boolean keyPressed(int key, int scanCode, int modifiers) {
return content.keyPressed(key, scanCode, modifiers) || super.keyPressed(key, scanCode, modifiers);
}
@Override
public boolean charTyped(char character, int modifiers) {
return content.charTyped(character, modifiers) || super.charTyped(character, modifiers);
}
/** Escape leaves the screen; the draft is applied on the way out. */
@Override
public boolean closeOnEscape() {
return !content.hasFocus();
}
@Override
public void closed() {
ui.applyDraft();
}
}
@@ -0,0 +1,461 @@
package dev.photosync.ui.screen;
import dev.photosync.core.config.BrowserSettings;
import dev.photosync.core.provider.RemoteAsset;
import dev.photosync.core.provider.ThumbnailSize;
import dev.photosync.core.timeline.TimelineBrowser;
import dev.photosync.core.timeline.TimelineSection;
import dev.photosync.core.timeline.TimelineState;
import dev.photosync.mcapi.Keys;
import dev.photosync.mcapi.render.RenderBridge;
import dev.photosync.mcapi.render.TextureHandle;
import dev.photosync.ui.PhotoSyncUi;
import dev.photosync.ui.Rect;
import dev.photosync.ui.image.ThumbnailCache;
import dev.photosync.ui.widget.Button;
import dev.photosync.ui.widget.ScrollModel;
import java.time.Duration;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
/**
* The album, by day, from now back to whenever the player started.
*
* <p>The trick that makes this usable over a library of tens of thousands of
* photos is that the backend can say how many assets each month holds without
* sending any of them. That count gives every section its exact height before a
* single byte of image data is fetched, so the scrollbar is honest from the
* first frame and the browser only ever asks for the months the viewport
* actually reaches -- the same bargain a virtual list makes on the web, made
* against a server that was designed for it.
*
* <p>A month arrives already split into days by {@link TimelineBrowser}, and
* replaces its own placeholder section in place. Scroll position is measured in
* pixels from the top of the content, and the swap does not move it, so the
* grid fills in around the player rather than jumping under them.
*/
public final class TimelineScreen extends PhotoSyncScreen {
/** One section's slot in the scrollable column, measured once per layout. */
private record Block(TimelineSection section, int top, int height, int columns, int tileSize) {
private int gridTop(int headerHeight) {
return top + headerHeight;
}
}
private final TimelineBrowser browser;
private final ScrollModel scroll;
private final ThumbnailCache tiles;
private final ThumbnailCache detail;
private final DateTimeFormatter dayFormat =
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.getDefault());
private final DateTimeFormatter monthFormat =
DateTimeFormatter.ofPattern("LLLL yyyy", Locale.getDefault());
private Rect grid = Rect.EMPTY;
private List<Block> blocks = List.of();
private int measuredRevision = -1;
private int measuredWidth = -1;
private int measuredTileSize = -1;
private int headerHeight = 14;
private RemoteAsset opened;
private Button albumToggle;
public TimelineScreen(PhotoSyncUi ui) {
super(ui);
this.browser = ui.core().browser();
this.scroll = new ScrollModel(ui.chrome());
BrowserSettings settings = ui.core().config().current().browser();
this.tiles = new ThumbnailCache(ui.bridge().textures(), ui.core().thumbnails(),
ThumbnailSize.GRID, settings.thumbnailCacheEntries());
// Three is enough for the one open photo and the two either side of it.
this.detail = new ThumbnailCache(ui.bridge().textures(), ui.core().thumbnails(), ThumbnailSize.DETAIL, 3);
}
@Override
protected Tab tab() {
return Tab.BROWSE;
}
// -----------------------------------------------------------------------
// Layout
// -----------------------------------------------------------------------
@Override
protected void layoutBody(Rect area) {
// Opens the album on first sight of this screen, and is a no-op on every
// resize and tab switch after that.
ui.ensureBrowsing();
int gap = theme().gap();
Rect actions = area.bottom(theme().controlHeight());
grid = area.dropBottom(theme().controlHeight() + gap);
scroll.viewport(grid);
// A resize invalidates the column count, so re-measure on the next frame.
measuredWidth = -1;
int half = (actions.width() - gap) / 2;
albumToggle = widgets.add(new Button(chrome, albumLabel(), () -> {
ui.browsingLibrary(!ui.browsingLibrary());
albumToggle.label(albumLabel());
}));
albumToggle.bounds(actions.left(half));
Button refresh = widgets.add(new Button(chrome, chrome.translate("photosync.browse.refresh"),
() -> {
tiles.clear();
browser.reload();
}));
refresh.bounds(actions.right(half));
}
private String albumLabel() {
return chrome.translate(ui.browsingLibrary()
? "photosync.browse.showing_library"
: "photosync.browse.showing_album");
}
/**
* Rebuilds the column when something that changes its shape has changed:
* new sections, a resize, or a new tile size.
*/
private void measure(RenderBridge render) {
BrowserSettings settings = ui.core().config().current().browser();
int tileSize = settings.tileSize();
int revision = browser.revision();
int usable = grid.width() - scroll.gutter();
if (revision == measuredRevision && usable == measuredWidth && tileSize == measuredTileSize) {
return;
}
measuredRevision = revision;
measuredWidth = usable;
measuredTileSize = tileSize;
tiles.capacity(settings.thumbnailCacheEntries());
headerHeight = render.lineHeight() + 6;
int tileGap = theme().tileGap();
int columns = Math.max(1, (usable + tileGap) / (tileSize + tileGap));
List<Block> built = new ArrayList<>();
int top = 0;
for (TimelineSection section : browser.sections()) {
int rows = Math.max(1, (section.assetCount() + columns - 1) / columns);
int height = headerHeight + rows * (tileSize + tileGap);
built.add(new Block(section, top, height, columns, tileSize));
top += height;
}
blocks = List.copyOf(built);
scroll.contentHeight(top);
}
// -----------------------------------------------------------------------
// Rendering
// -----------------------------------------------------------------------
@Override
protected void renderBody(RenderBridge render, int mouseX, int mouseY) {
measure(render);
scroll.advance(System.currentTimeMillis());
chrome.well(render, grid);
if (renderState(render)) {
renderGrid(render, mouseX, mouseY);
scroll.render(render, mouseX, mouseY);
}
if (opened != null) {
renderOpened(render, mouseX, mouseY);
}
}
/** Draws whatever stands in for the grid, and says whether the grid itself should be drawn. */
private boolean renderState(RenderBridge render) {
switch (browser.state()) {
case NOT_CONFIGURED -> {
chrome.notice(render, grid, chrome.translate("photosync.browse.not_configured"),
chrome.translate("photosync.browse.not_configured.hint"));
return false;
}
case LOADING -> {
chrome.notice(render, grid.dropBottom(render.lineHeight() + 8),
chrome.translate("photosync.browse.loading"), "");
chrome.busyBar(render, new Rect(grid.centerX() - 60, grid.centerY() + 8, 120, 3),
System.currentTimeMillis(), theme().accent());
return false;
}
case FAILED -> {
chrome.notice(render, grid, chrome.translate("photosync.browse.failed"),
browser.error().orElse(""));
return false;
}
case EMPTY -> {
chrome.notice(render, grid, chrome.translate("photosync.browse.empty"), "");
return false;
}
case READY -> {
return !blocks.isEmpty();
}
default -> {
return false;
}
}
}
private void renderGrid(RenderBridge render, int mouseX, int mouseY) {
tiles.beginFrame();
int offset = scroll.offset();
// One viewport of lookahead, so a month is already being fetched by the
// time the player scrolls it into view.
int prefetchTop = offset - grid.height();
int prefetchBottom = offset + grid.height() * 2;
render.pushClip(grid.x(), grid.y(), grid.width(), grid.height());
for (int i = firstVisible(prefetchTop); i < blocks.size(); i++) {
Block block = blocks.get(i);
if (block.top() > prefetchBottom) {
break;
}
if (block.section() instanceof TimelineSection.PendingMonth pending) {
browser.request(pending.bucket());
}
boolean onScreen = block.top() + block.height() >= offset && block.top() <= offset + grid.height();
if (onScreen) {
renderBlock(render, block, offset, mouseX, mouseY);
}
}
render.popClip();
tiles.endFrame();
}
private void renderBlock(RenderBridge render, Block block, int offset, int mouseX, int mouseY) {
int y = grid.y() + block.top() - offset;
TimelineSection section = block.section();
TimelineSection.PendingMonth pending =
section instanceof TimelineSection.PendingMonth month ? month : null;
render.fill(grid.x(), y, grid.width() - scroll.gutter(), headerHeight, theme().fade(theme().surface(), 0.5f));
chrome.label(render, headerLabel(section, pending != null), grid.x() + 4, y + 3, theme().textMuted());
String count = String.valueOf(section.assetCount());
render.text(count, grid.x() + grid.width() - scroll.gutter() - render.textWidth(count) - 4, y + 3,
theme().textFaint(), false);
int tileGap = theme().tileGap();
int tileSize = block.tileSize();
List<RemoteAsset> assets = section.assets();
for (int index = 0; index < section.assetCount(); index++) {
int column = index % block.columns();
int row = index / block.columns();
Rect tile = new Rect(
grid.x() + column * (tileSize + tileGap),
y + headerHeight + row * (tileSize + tileGap),
tileSize, tileSize);
if (tile.bottom() < grid.y() || tile.y() > grid.bottom()) {
continue;
}
if (index < assets.size()) {
renderTile(render, assets.get(index), tile, tile.contains(mouseX, mouseY));
} else {
// A month whose page has not arrived: the count is exact, so the
// space is already correct and only the picture is missing.
render.fill(tile.x(), tile.y(), tile.width(), tile.height(), theme().tilePlaceholder());
}
}
if (pending != null && browser.pageError(pending.bucket()).isPresent()) {
chrome.label(render, chrome.translate("photosync.browse.page_failed"),
grid.x() + 4, y + headerHeight + 2, theme().danger());
}
}
private void renderTile(RenderBridge render, RemoteAsset asset, Rect tile, boolean hovered) {
render.fill(tile.x(), tile.y(), tile.width(), tile.height(), theme().tilePlaceholder());
Optional<ThumbnailCache.Thumbnail> thumbnail = tiles.of(asset);
thumbnail.ifPresent(value -> drawCropped(render, value.texture(), tile));
if (thumbnail.isEmpty() && tiles.failed(asset.id())) {
chrome.centered(render, "!", tile, theme().danger());
}
if (asset.isVideo() && ui.core().config().current().browser().showVideoBadge()) {
chrome.videoMarker(render, tile, formatDuration(asset.duration()));
}
if (hovered) {
render.border(tile.x(), tile.y(), tile.width(), tile.height(), theme().accent());
}
}
/** Centre-crops to a square. Squashing a landscape shot into a tile looks broken. */
private void drawCropped(RenderBridge render, TextureHandle texture, Rect tile) {
float aspect = texture.width() / (float) Math.max(1, texture.height());
float half = 0.5f;
float uHalf = aspect > 1 ? half / aspect : half;
float vHalf = aspect > 1 ? half : half * aspect;
render.image(texture, tile.x(), tile.y(), tile.width(), tile.height(),
half - uHalf, half - vHalf, half + uHalf, half + vHalf);
}
private void renderOpened(RenderBridge render, int mouseX, int mouseY) {
detail.beginFrame();
Rect area = body();
render.fill(area.x(), area.y(), area.width(), area.height(), theme().overlay());
Rect frame = area.inset(6);
Optional<ThumbnailCache.Thumbnail> image = detail.of(opened);
if (image.isPresent()) {
drawContained(render, image.get().texture(), frame);
} else {
chrome.notice(render, frame, chrome.translate("photosync.browse.opening"), "");
}
chrome.centered(render, chrome.translate("photosync.browse.close_hint"),
area.bottom(render.lineHeight() + 2), theme().textFaint());
detail.endFrame();
}
/** Fits the whole image inside {@code area}, preserving its shape. */
private void drawContained(RenderBridge render, TextureHandle texture, Rect area) {
double scale = Math.min(area.width() / (double) texture.width(), area.height() / (double) texture.height());
int width = Math.max(1, (int) Math.round(texture.width() * scale));
int height = Math.max(1, (int) Math.round(texture.height() * scale));
render.image(texture, area.centerX() - width / 2, area.centerY() - height / 2, width, height);
}
private String headerLabel(TimelineSection section, boolean pending) {
LocalDate date = section.date();
return pending ? monthFormat.format(date) : dayFormat.format(date);
}
private String formatDuration(Duration duration) {
if (duration.isZero() || duration.isNegative()) {
return "";
}
long total = duration.getSeconds();
return String.format(Locale.ROOT, "%d:%02d", total / 60, total % 60);
}
/** Binary search for the first block whose bottom edge is at or past {@code y}. */
private int firstVisible(int y) {
int low = 0;
int high = blocks.size() - 1;
int found = blocks.size();
while (low <= high) {
int mid = (low + high) >>> 1;
Block block = blocks.get(mid);
if (block.top() + block.height() >= y) {
found = mid;
high = mid - 1;
} else {
low = mid + 1;
}
}
return found;
}
@Override
protected String footerText() {
if (browser.state() != TimelineState.READY) {
return "";
}
return chrome.translate("photosync.browse.status", browser.assetCount(), blocks.size());
}
// -----------------------------------------------------------------------
// Input
// -----------------------------------------------------------------------
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
if (opened != null) {
opened = null;
return true;
}
if (scroll.mouseClicked(mouseX, mouseY, button)) {
return true;
}
if (button == 0 && grid.contains(mouseX, mouseY)) {
assetAt(mouseX, mouseY).ifPresent(asset -> opened = asset);
return true;
}
return super.mouseClicked(mouseX, mouseY, button);
}
@Override
public boolean mouseReleased(double mouseX, double mouseY, int button) {
return scroll.mouseReleased() || super.mouseReleased(mouseX, mouseY, button);
}
@Override
public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
return scroll.mouseDragged(mouseX, mouseY, button)
|| super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY);
}
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double amount) {
int step = measuredTileSize > 0 ? measuredTileSize + theme().tileGap() : theme().rowHeight();
return scroll.mouseScrolled(mouseX, mouseY, amount, step)
|| super.mouseScrolled(mouseX, mouseY, amount);
}
@Override
public boolean keyPressed(int key, int scanCode, int modifiers) {
if (opened != null && key == Keys.ESCAPE) {
opened = null;
return true;
}
return switch (key) {
case Keys.PAGE_UP -> {
scroll.scrollBy(-grid.height());
yield true;
}
case Keys.PAGE_DOWN -> {
scroll.scrollBy(grid.height());
yield true;
}
case Keys.HOME -> {
scroll.scrollTo(0);
yield true;
}
case Keys.END -> {
scroll.scrollTo(scroll.maxOffset());
yield true;
}
default -> super.keyPressed(key, scanCode, modifiers);
};
}
/** Escape dismisses the open photo before it dismisses the screen. */
@Override
public boolean closeOnEscape() {
return opened == null;
}
private Optional<RemoteAsset> assetAt(double mouseX, double mouseY) {
int y = (int) (mouseY - grid.y()) + scroll.offset();
int index = firstVisible(y);
if (index >= blocks.size()) {
return Optional.empty();
}
Block block = blocks.get(index);
int withinGrid = y - block.gridTop(headerHeight);
if (withinGrid < 0) {
return Optional.empty();
}
int stride = block.tileSize() + theme().tileGap();
int column = (int) (mouseX - grid.x()) / stride;
int row = withinGrid / stride;
if (column < 0 || column >= block.columns()) {
return Optional.empty();
}
int position = row * block.columns() + column;
List<RemoteAsset> assets = block.section().assets();
return position >= 0 && position < assets.size() ? Optional.of(assets.get(position)) : Optional.empty();
}
@Override
public void closed() {
tiles.close();
detail.close();
}
}
@@ -0,0 +1,112 @@
package dev.photosync.ui.widget;
import dev.photosync.mcapi.render.RenderBridge;
import dev.photosync.ui.Chrome;
import lombok.Setter;
import lombok.experimental.Accessors;
/**
* A labelled rectangle that runs something when clicked.
*
* <p>The label is mutable because several of them change in place -- "Test
* connection" becomes "Testing..." and then "Connected" -- and swapping the
* widget out would lose the layout it was given.
*/
@Accessors(fluent = true)
public final class Button extends Widget {
/** How much a button asks to stand out. */
public enum Emphasis {
/** The obvious next action. One per screen at most. */
PRIMARY,
/** Everything else. */
NORMAL,
/** Destructive: cancel an upload, quit with work outstanding. */
DANGER
}
@Setter
private String label;
@Setter
private Emphasis emphasis = Emphasis.NORMAL;
private final Runnable action;
private boolean pressed;
public Button(Chrome chrome, String label, Runnable action) {
super(chrome);
this.label = label;
this.action = action;
}
public Button emphasized(Emphasis value) {
this.emphasis = value;
return this;
}
@Override
public void render(RenderBridge render, int mouseX, int mouseY) {
boolean hovered = hovering(mouseX, mouseY);
int background = background(hovered);
render.fill(bounds().x(), bounds().y(), bounds().width(), bounds().height(), background);
render.border(bounds().x(), bounds().y(), bounds().width(), bounds().height(), borderColour(hovered));
chrome.centered(render, chrome.elide(render, label, bounds().width() - 6), bounds(), foreground());
}
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
if (button != 0 || !hovering(mouseX, mouseY)) {
return false;
}
pressed = true;
return true;
}
@Override
public boolean mouseReleased(double mouseX, double mouseY, int button) {
if (!pressed) {
return false;
}
pressed = false;
// Only fire if the cursor is still on the button, so a click can be
// taken back by dragging off it -- the convention everywhere else.
if (hovering(mouseX, mouseY)) {
action.run();
}
return true;
}
private int background(boolean hovered) {
if (!enabled()) {
return theme().fade(theme().surface(), 0.6f);
}
return switch (emphasis) {
case PRIMARY -> pressed || hovered ? theme().accentHover() : theme().accent();
case DANGER -> pressed ? theme().danger() : theme().surfaceFor(hovered, false);
case NORMAL -> theme().surfaceFor(hovered, pressed);
};
}
private int borderColour(boolean hovered) {
if (!enabled()) {
return theme().panelBorder();
}
return switch (emphasis) {
case PRIMARY -> theme().accentHover();
case DANGER -> hovered ? theme().danger() : theme().panelBorder();
case NORMAL -> hovered ? theme().scrollThumbHover() : theme().panelBorder();
};
}
private int foreground() {
if (!enabled()) {
return theme().textFaint();
}
return switch (emphasis) {
case PRIMARY -> theme().accentText();
case DANGER -> theme().danger();
case NORMAL -> theme().text();
};
}
}
@@ -0,0 +1,226 @@
package dev.photosync.ui.widget;
import dev.photosync.mcapi.render.RenderBridge;
import dev.photosync.ui.Chrome;
import dev.photosync.ui.Rect;
import dev.photosync.ui.Theme;
import lombok.Getter;
import lombok.experimental.Accessors;
/**
* A vertically scrolling viewport: how far down it is, how far down it may go,
* and the bar on the right that says so.
*
* <p>It is not a {@link Widget} because it does not draw the thing being
* scrolled. The queue draws rows and the timeline draws a grid of tiles, and
* both need to interleave their own clipping and their own hit-testing with the
* offset -- so they own a ScrollModel and ask it questions, rather than handing
* it their content.
*
* <p>Movement is eased against the wall clock rather than snapping. The
* difference is entirely cosmetic and entirely worth it: a timeline that jumps
* by exactly one row per notch reads as a list of rows, while one that glides
* reads as a photo album.
*/
@Accessors(fluent = true)
public final class ScrollModel {
private static final int BAR_WIDTH = 4;
private static final int MIN_THUMB_HEIGHT = 16;
private final Chrome chrome;
@Getter
private Rect viewport = Rect.EMPTY;
@Getter
private int contentHeight;
private double offset;
private double target;
private long lastFrameMillis;
private boolean draggingThumb;
private double dragAnchor;
public ScrollModel(Chrome chrome) {
this.chrome = chrome;
}
// -----------------------------------------------------------------------
// Geometry
// -----------------------------------------------------------------------
/** Called from the screen's layout pass. Keeps the current position where it can. */
public void viewport(Rect bounds) {
this.viewport = bounds;
clampImmediately();
}
/**
* Called whenever the content grows or shrinks -- a month loading in the
* timeline, an upload finishing in the queue.
*/
public void contentHeight(int height) {
this.contentHeight = Math.max(0, height);
clampImmediately();
}
public int maxOffset() {
return Math.max(0, contentHeight - viewport.height());
}
public boolean scrollable() {
return maxOffset() > 0;
}
/** The current position, rounded, for the arithmetic that positions content. */
public int offset() {
return (int) Math.round(offset);
}
/** Where the view is heading, which is what "scroll another notch" builds on. */
public double destination() {
return target;
}
public void scrollBy(double delta) {
scrollTo(target + delta);
}
public void scrollTo(double position) {
target = Math.max(0, Math.min(maxOffset(), position));
}
/** Moves without animating -- for jumping to a date, or restoring a saved position. */
public void jumpTo(double position) {
scrollTo(position);
offset = target;
}
private void clampImmediately() {
target = Math.max(0, Math.min(maxOffset(), target));
offset = Math.max(0, Math.min(maxOffset(), offset));
}
// -----------------------------------------------------------------------
// Animation
// -----------------------------------------------------------------------
/**
* Advances the eased position. Call once per frame before drawing content,
* so the content and the bar agree on where the view is.
*
* <p>Wall clock, not ticks: PhotoSync screens usually pause the game, and a
* tick-driven animation would sit still.
*/
public void advance(long nowMillis) {
long elapsed = lastFrameMillis == 0 ? 16 : Math.max(0, Math.min(100, nowMillis - lastFrameMillis));
lastFrameMillis = nowMillis;
double remaining = target - offset;
if (Math.abs(remaining) < 0.5) {
offset = target;
return;
}
offset += remaining * (1 - Math.exp(-elapsed / 45.0));
}
// -----------------------------------------------------------------------
// Input
// -----------------------------------------------------------------------
/**
* @param step how far one notch travels -- a row for the queue, a tile row
* for the timeline
* @return whether the wheel was over this viewport and did something
*/
public boolean mouseScrolled(double mouseX, double mouseY, double amount, int step) {
if (!scrollable() || !viewport.contains(mouseX, mouseY)) {
return false;
}
scrollBy(-amount * step);
return true;
}
public boolean mouseClicked(double mouseX, double mouseY, int button) {
if (button != 0 || !scrollable() || !trackBounds().contains(mouseX, mouseY)) {
return false;
}
Rect thumb = thumbBounds();
if (thumb.contains(mouseX, mouseY)) {
dragAnchor = mouseY - thumb.y();
} else {
// Clicking the empty track centres the thumb on the cursor, which is
// what a long list needs; paging by a screen would take forever.
dragAnchor = thumb.height() / 2.0;
jumpToThumbTop(mouseY - dragAnchor);
}
draggingThumb = true;
return true;
}
public boolean mouseDragged(double mouseX, double mouseY, int button) {
if (!draggingThumb) {
return false;
}
jumpToThumbTop(mouseY - dragAnchor);
return true;
}
public boolean mouseReleased() {
boolean was = draggingThumb;
draggingThumb = false;
return was;
}
/** Dragging the bar is a direct manipulation, so it does not ease behind the cursor. */
private void jumpToThumbTop(double thumbTop) {
Rect track = trackBounds();
double travel = track.height() - thumbBounds().height();
double ratio = travel <= 0 ? 0 : (thumbTop - track.y()) / travel;
jumpTo(Math.max(0, Math.min(1, ratio)) * maxOffset());
}
// -----------------------------------------------------------------------
// The bar
// -----------------------------------------------------------------------
/** The full-height strip the thumb runs in, at the right edge of the viewport. */
public Rect trackBounds() {
return new Rect(viewport.right() - BAR_WIDTH, viewport.y(), BAR_WIDTH, viewport.height());
}
public Rect thumbBounds() {
Rect track = trackBounds();
if (!scrollable()) {
return track;
}
int height = Math.max(MIN_THUMB_HEIGHT,
(int) ((long) track.height() * viewport.height() / contentHeight));
int travel = track.height() - height;
int y = track.y() + (int) Math.round(travel * (offset / maxOffset()));
return new Rect(track.x(), y, track.width(), height);
}
/**
* The width the content should leave clear on the right. Zero when the view
* is not scrollable, so a short list uses the full width rather than
* reserving space for a bar that is not there.
*/
public int gutter() {
return scrollable() ? BAR_WIDTH + 2 : 0;
}
public void render(RenderBridge render, int mouseX, int mouseY) {
if (!scrollable()) {
return;
}
Theme theme = chrome.theme();
Rect track = trackBounds();
Rect thumb = thumbBounds();
render.fill(track.x(), track.y(), track.width(), track.height(), theme.scrollTrack());
boolean hot = draggingThumb || thumb.contains(mouseX, mouseY);
render.fill(thumb.x(), thumb.y(), thumb.width(), thumb.height(),
hot ? theme.scrollThumbHover() : theme.scrollThumb());
}
}
@@ -0,0 +1,135 @@
package dev.photosync.ui.widget;
import dev.photosync.mcapi.Keys;
import dev.photosync.mcapi.render.RenderBridge;
import dev.photosync.ui.Chrome;
import lombok.experimental.Accessors;
import java.util.function.IntConsumer;
import java.util.function.IntSupplier;
/**
* An integer chosen by dragging, shown as the label the player actually cares
* about rather than the raw number.
*
* <p>Every numeric setting in PhotoSync has a sensible range that the config
* clamps to anyway, which is what makes a slider the right control here: it
* cannot produce a value the mod would have to reject, so there is no error
* state to design.
*/
@Accessors(fluent = true)
public final class Slider extends Widget {
private final IntSupplier reader;
private final IntConsumer writer;
private final int minimum;
private final int maximum;
private final Formatter labels;
/** Turns the raw value into the text drawn on the track, e.g. {@code 300 -> "5 min"}. */
@FunctionalInterface
public interface Formatter {
String format(int value);
}
private boolean dragging;
public Slider(Chrome chrome, IntSupplier reader, IntConsumer writer, int minimum, int maximum, Formatter labels) {
super(chrome);
this.reader = reader;
this.writer = writer;
this.minimum = minimum;
this.maximum = Math.max(minimum + 1, maximum);
this.labels = labels;
}
/** Focusable so the arrow keys can nudge it after a click. */
@Override
public boolean focusable() {
return true;
}
@Override
public void render(RenderBridge render, int mouseX, int mouseY) {
boolean hovered = hovering(mouseX, mouseY);
int value = clamp(reader.getAsInt());
render.fill(bounds().x(), bounds().y(), bounds().width(), bounds().height(), theme().surfaceSunken());
render.border(bounds().x(), bounds().y(), bounds().width(), bounds().height(),
hovered || dragging ? theme().scrollThumbHover() : theme().panelBorder());
int handleWidth = 6;
int travel = bounds().width() - 2 - handleWidth;
int handleX = bounds().x() + 1 + (int) Math.round(travel * fraction(value));
render.fill(bounds().x() + 1, bounds().y() + 1, handleX - bounds().x() - 1, bounds().height() - 2,
enabled() ? theme().fade(theme().accent(), 0.45f) : theme().surface());
render.fill(handleX, bounds().y() + 1, handleWidth, bounds().height() - 2,
enabled() ? (dragging || hovered ? theme().accentHover() : theme().accent()) : theme().textFaint());
chrome.centered(render, labels.format(value), bounds(),
enabled() ? theme().text() : theme().textFaint());
}
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
if (button != 0 || !hovering(mouseX, mouseY)) {
return false;
}
dragging = true;
applyAt(mouseX);
return true;
}
@Override
public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
if (!dragging) {
return false;
}
applyAt(mouseX);
return true;
}
@Override
public boolean mouseReleased(double mouseX, double mouseY, int button) {
boolean was = dragging;
dragging = false;
return was;
}
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double amount) {
if (!hovering(mouseX, mouseY)) {
return false;
}
writer.accept(clamp(reader.getAsInt() + (int) Math.signum(amount) * step()));
return true;
}
@Override
public boolean keyPressed(int key, int scanCode, int modifiers) {
int direction = key == Keys.LEFT ? -1 : key == Keys.RIGHT ? 1 : 0;
if (direction == 0) {
return false;
}
writer.accept(clamp(reader.getAsInt() + direction * step()));
return true;
}
private void applyAt(double mouseX) {
double ratio = (mouseX - bounds().x() - 3) / Math.max(1, bounds().width() - 6);
writer.accept(clamp(minimum + (int) Math.round(ratio * (maximum - minimum))));
}
/** One percent of the range, so a keypress or wheel notch moves a useful amount. */
private int step() {
return Math.max(1, (maximum - minimum) / 100);
}
private double fraction(int value) {
return (double) (value - minimum) / (maximum - minimum);
}
private int clamp(int value) {
return Math.max(minimum, Math.min(maximum, value));
}
}
@@ -0,0 +1,287 @@
package dev.photosync.ui.widget;
import dev.photosync.mcapi.Clipboard;
import dev.photosync.mcapi.Keys;
import dev.photosync.mcapi.render.RenderBridge;
import dev.photosync.ui.Chrome;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;
import java.util.function.Consumer;
/**
* A single line of editable text.
*
* <p>Paste is the reason this widget is as complete as it is. An Immich API key
* is sixty-odd random characters that nobody types by hand, so Control-V --
* Command-V on macOS -- has to work, and once you have paste you need a
* selection to paste over, and once you have a selection you need shift-arrow
* and Control-A to make one.
*
* <p>{@code masked} draws asterisks. It is display-only: the value is still the
* real string, and the widget offers no "reveal" toggle because the player can
* always paste it somewhere they control.
*/
@Accessors(fluent = true)
public final class TextField extends Widget {
private final Clipboard clipboard;
private final Consumer<String> onChange;
@Getter
private String value = "";
private int cursor;
private int selectionAnchor;
private int scrollX;
@Setter
private String hint = "";
@Setter
private boolean masked;
@Setter
private int maxLength = 512;
public TextField(Chrome chrome, Clipboard clipboard, Consumer<String> onChange) {
super(chrome);
this.clipboard = clipboard;
this.onChange = onChange;
}
/** Replaces the contents without notifying {@code onChange} -- for loading a draft in. */
public void reset(String newValue) {
this.value = newValue == null ? "" : newValue;
this.cursor = this.value.length();
this.selectionAnchor = this.cursor;
this.scrollX = 0;
}
@Override
public boolean focusable() {
return true;
}
@Override
public void focus(boolean value) {
super.focus(value);
if (!value) {
selectionAnchor = cursor;
}
}
// -----------------------------------------------------------------------
// Rendering
// -----------------------------------------------------------------------
@Override
public void render(RenderBridge render, int mouseX, int mouseY) {
boolean hovered = hovering(mouseX, mouseY);
render.fill(bounds().x(), bounds().y(), bounds().width(), bounds().height(), theme().surfaceSunken());
render.border(bounds().x(), bounds().y(), bounds().width(), bounds().height(),
focused() ? theme().accent() : hovered ? theme().scrollThumbHover() : theme().panelBorder());
int innerX = bounds().x() + 4;
int innerWidth = bounds().width() - 8;
int baseline = bounds().y() + (bounds().height() - render.lineHeight()) / 2 + 1;
if (value.isEmpty() && !focused()) {
render.text(chrome.elide(render, hint, innerWidth), innerX, baseline, theme().textFaint(), false);
return;
}
String shown = displayed();
keepCursorVisible(render, innerWidth);
render.pushClip(innerX, bounds().y() + 1, innerWidth, bounds().height() - 2);
int textX = innerX - scrollX;
if (hasSelection()) {
int from = Math.min(cursor, selectionAnchor);
int to = Math.max(cursor, selectionAnchor);
int selectionStart = textX + render.textWidth(shown.substring(0, from));
int selectionWidth = render.textWidth(shown.substring(from, to));
render.fill(selectionStart, bounds().y() + 2, selectionWidth, bounds().height() - 4,
theme().fade(theme().accent(), 0.45f));
}
render.text(shown, textX, baseline, enabled() ? theme().text() : theme().textFaint(), false);
// 530ms is close enough to the blink rate everything else uses that it
// reads as a cursor rather than as something being wrong.
if (focused() && (System.currentTimeMillis() / 530) % 2 == 0) {
int caretX = textX + render.textWidth(shown.substring(0, cursor));
render.fill(caretX, bounds().y() + 3, 1, bounds().height() - 6, theme().text());
}
render.popClip();
}
private void keepCursorVisible(RenderBridge render, int innerWidth) {
String shown = displayed();
int caretX = render.textWidth(shown.substring(0, cursor));
if (caretX - scrollX > innerWidth - 2) {
scrollX = caretX - innerWidth + 2;
}
if (caretX - scrollX < 0) {
scrollX = caretX;
}
int total = render.textWidth(shown);
scrollX = Math.max(0, Math.min(scrollX, Math.max(0, total - innerWidth + 2)));
}
private String displayed() {
return masked ? "*".repeat(value.length()) : value;
}
// -----------------------------------------------------------------------
// Input
// -----------------------------------------------------------------------
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
if (button != 0 || !hovering(mouseX, mouseY)) {
return false;
}
// Focus is enough to place the caret sensibly for these fields, which
// hold a URL or a key rather than prose. Click-to-position would need a
// font metric per character and buys very little here.
cursor = value.length();
selectionAnchor = 0;
return true;
}
@Override
public boolean keyPressed(int key, int scanCode, int modifiers) {
if (!focused() || !enabled()) {
return false;
}
if (Keys.shortcut(modifiers)) {
return shortcut(key);
}
return switch (key) {
case Keys.BACKSPACE -> {
if (hasSelection()) {
deleteSelection();
} else if (cursor > 0) {
replace(cursor - 1, cursor, "");
}
yield true;
}
case Keys.DELETE -> {
if (hasSelection()) {
deleteSelection();
} else if (cursor < value.length()) {
replace(cursor, cursor + 1, "");
}
yield true;
}
case Keys.LEFT -> {
moveTo(Math.max(0, cursor - 1), Keys.shift(modifiers));
yield true;
}
case Keys.RIGHT -> {
moveTo(Math.min(value.length(), cursor + 1), Keys.shift(modifiers));
yield true;
}
case Keys.HOME -> {
moveTo(0, Keys.shift(modifiers));
yield true;
}
case Keys.END -> {
moveTo(value.length(), Keys.shift(modifiers));
yield true;
}
// Swallowed so the screen does not also act on them while typing.
case Keys.ESCAPE, Keys.TAB -> false;
default -> Keys.confirms(key);
};
}
private boolean shortcut(int key) {
switch (key) {
case Keys.A -> {
selectionAnchor = 0;
cursor = value.length();
return true;
}
case Keys.C -> {
if (hasSelection() && !masked) {
clipboard.write(selectedText());
}
return true;
}
case Keys.X -> {
if (hasSelection()) {
if (!masked) {
clipboard.write(selectedText());
}
deleteSelection();
}
return true;
}
case Keys.V -> {
insert(clipboard.read());
return true;
}
default -> {
return false;
}
}
}
@Override
public boolean charTyped(char character, int modifiers) {
if (!focused() || !enabled() || character < ' ' || character == 127) {
return false;
}
insert(String.valueOf(character));
return true;
}
// -----------------------------------------------------------------------
// Editing
// -----------------------------------------------------------------------
private void insert(String text) {
if (text == null || text.isEmpty()) {
return;
}
// Newlines and control characters arrive via paste and would render as
// boxes; a pasted key with a trailing newline is the common case.
String clean = text.replaceAll("[\\p{Cntrl}]", "");
int from = Math.min(cursor, selectionAnchor);
int to = Math.max(cursor, selectionAnchor);
int room = maxLength - (value.length() - (to - from));
if (room <= 0) {
return;
}
replace(from, to, clean.length() > room ? clean.substring(0, room) : clean);
}
private void deleteSelection() {
replace(Math.min(cursor, selectionAnchor), Math.max(cursor, selectionAnchor), "");
}
private void replace(int from, int to, String replacement) {
value = value.substring(0, from) + replacement + value.substring(to);
cursor = from + replacement.length();
selectionAnchor = cursor;
onChange.accept(value);
}
private void moveTo(int position, boolean extendSelection) {
cursor = position;
if (!extendSelection) {
selectionAnchor = cursor;
}
}
private boolean hasSelection() {
return cursor != selectionAnchor;
}
private String selectedText() {
return value.substring(Math.min(cursor, selectionAnchor), Math.max(cursor, selectionAnchor));
}
}
@@ -0,0 +1,90 @@
package dev.photosync.ui.widget;
import dev.photosync.mcapi.render.RenderBridge;
import dev.photosync.ui.Chrome;
import dev.photosync.ui.Rect;
import lombok.Setter;
import lombok.experimental.Accessors;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer;
/**
* An on/off switch with a label, and an optional line of explanation under it.
*
* <p>It reads and writes through a supplier and a consumer rather than holding
* a boolean. The settings screen edits a draft config, and a widget with its own
* copy of the value is a second source of truth that drifts the moment anything
* else -- a reset button, a provider change -- touches the draft.
*/
@Accessors(fluent = true)
public final class Toggle extends Widget {
private final String label;
private final BooleanSupplier reader;
private final Consumer<Boolean> writer;
@Setter
private String description;
public Toggle(Chrome chrome, String label, BooleanSupplier reader, Consumer<Boolean> writer) {
super(chrome);
this.label = label;
this.reader = reader;
this.writer = writer;
}
public Toggle describedAs(String value) {
this.description = value;
return this;
}
@Override
public void render(RenderBridge render, int mouseX, int mouseY) {
boolean on = reader.getAsBoolean();
boolean hovered = hovering(mouseX, mouseY);
int size = 11;
int boxY = bounds().y() + (render.lineHeight() - size) / 2 + 1;
Rect box = new Rect(bounds().x(), boxY, size, size);
render.fill(box.x(), box.y(), box.width(), box.height(),
on ? theme().accent() : theme().surfaceFor(hovered, false));
render.border(box.x(), box.y(), box.width(), box.height(),
on ? theme().accentHover() : hovered ? theme().scrollThumbHover() : theme().panelBorder());
if (on) {
// A check mark from two runs: a short down-right stroke and a long
// up-right one. Legible at eleven pixels, which a glyph would not be.
for (int i = 0; i < 3; i++) {
render.fill(box.x() + 2 + i, box.y() + 4 + i, 1, 2, theme().accentText());
}
for (int i = 0; i < 4; i++) {
render.fill(box.x() + 5 + i, box.y() + 6 - i, 1, 2, theme().accentText());
}
}
int textX = box.right() + 6;
int colour = enabled() ? theme().text() : theme().textFaint();
render.text(chrome.elide(render, label, bounds().right() - textX), textX, bounds().y() + 1, colour, true);
if (description != null && !description.isEmpty()) {
render.text(chrome.elide(render, description, bounds().right() - textX),
textX, bounds().y() + render.lineHeight() + 2, theme().textFaint(), false);
}
}
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
if (button != 0 || !hovering(mouseX, mouseY)) {
return false;
}
writer.accept(!reader.getAsBoolean());
return true;
}
/** Two lines when there is a description, one otherwise. */
public int preferredHeight(RenderBridge render) {
return description == null || description.isEmpty()
? render.lineHeight() + 4
: render.lineHeight() * 2 + 4;
}
}
@@ -0,0 +1,90 @@
package dev.photosync.ui.widget;
import dev.photosync.mcapi.render.RenderBridge;
import dev.photosync.ui.Chrome;
import dev.photosync.ui.Rect;
import dev.photosync.ui.Theme;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;
/**
* A rectangle that draws itself and may react to input.
*
* <p>The input methods mirror {@code ScreenModel}'s and answer the same
* question -- "did you consume this?" -- so a screen can hand an event to its
* widgets and pass the answer straight back to the game.
*/
@Getter
@Accessors(fluent = true)
public abstract class Widget {
protected final Chrome chrome;
@Setter
private Rect bounds = Rect.EMPTY;
@Setter
private boolean enabled = true;
@Setter
private boolean visible = true;
private boolean focused;
protected Widget(Chrome chrome) {
this.chrome = chrome;
}
protected Theme theme() {
return chrome.theme();
}
/** Visible and enabled: the only state in which a widget takes input. */
public boolean active() {
return visible && enabled;
}
public abstract void render(RenderBridge render, int mouseX, int mouseY);
/** Whether keyboard focus can land here. Only text entry says yes. */
public boolean focusable() {
return false;
}
/** Called by {@link WidgetList} when focus arrives or leaves. */
public void focus(boolean value) {
this.focused = value;
}
public boolean mouseClicked(double mouseX, double mouseY, int button) {
return false;
}
public boolean mouseReleased(double mouseX, double mouseY, int button) {
return false;
}
public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
return false;
}
public boolean mouseScrolled(double mouseX, double mouseY, double amount) {
return false;
}
public boolean keyPressed(int key, int scanCode, int modifiers) {
return false;
}
public boolean charTyped(char character, int modifiers) {
return false;
}
public void tick() {
}
protected boolean hovering(double mouseX, double mouseY) {
return active() && bounds.contains(mouseX, mouseY);
}
}
@@ -0,0 +1,137 @@
package dev.photosync.ui.widget;
import dev.photosync.mcapi.Keys;
import dev.photosync.mcapi.render.RenderBridge;
import java.util.ArrayList;
import java.util.List;
/**
* The widgets on one screen, and the two pieces of state that only make sense
* for the set as a whole: which one has keyboard focus, and which one is
* currently capturing the mouse.
*
* <p>Capture matters more than it looks. Without it, dragging a slider and
* letting the cursor slip off the track drops the drag, which every player
* notices and nobody reports as a bug -- they just conclude the slider is
* fiddly.
*/
public final class WidgetList {
private final List<Widget> widgets = new ArrayList<>();
private Widget focused;
private Widget capturing;
/** Returns its argument so a screen can add and keep a reference in one line. */
public <W extends Widget> W add(W widget) {
widgets.add(widget);
return widget;
}
public void clear() {
// Through focus(null) rather than by assignment, so the outgoing widget
// is told it lost focus and stops drawing a caret.
focus(null);
capturing = null;
widgets.clear();
}
public void render(RenderBridge render, int mouseX, int mouseY) {
for (Widget widget : widgets) {
if (widget.visible()) {
widget.render(render, mouseX, mouseY);
}
}
}
public void tick() {
widgets.forEach(Widget::tick);
}
public boolean mouseClicked(double mouseX, double mouseY, int button) {
// Reverse order so the widget drawn last -- and therefore on top --
// gets first refusal on the click.
for (int i = widgets.size() - 1; i >= 0; i--) {
Widget widget = widgets.get(i);
if (!widget.active()) {
continue;
}
if (widget.mouseClicked(mouseX, mouseY, button)) {
capturing = widget;
focus(widget.focusable() ? widget : null);
return true;
}
}
// A click on empty space commits whatever was being typed.
focus(null);
return false;
}
public boolean mouseReleased(double mouseX, double mouseY, int button) {
Widget target = capturing;
capturing = null;
return target != null && target.mouseReleased(mouseX, mouseY, button);
}
public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
return capturing != null && capturing.mouseDragged(mouseX, mouseY, button, deltaX, deltaY);
}
public boolean mouseScrolled(double mouseX, double mouseY, double amount) {
for (int i = widgets.size() - 1; i >= 0; i--) {
Widget widget = widgets.get(i);
if (widget.active() && widget.mouseScrolled(mouseX, mouseY, amount)) {
return true;
}
}
return false;
}
public boolean keyPressed(int key, int scanCode, int modifiers) {
if (focused != null && focused.active() && focused.keyPressed(key, scanCode, modifiers)) {
return true;
}
if (key == Keys.TAB && !widgets.isEmpty()) {
return cycleFocus(Keys.shift(modifiers) ? -1 : 1);
}
return false;
}
public boolean charTyped(char character, int modifiers) {
return focused != null && focused.active() && focused.charTyped(character, modifiers);
}
/** True while a text field is taking input, so Escape can close the field rather than the screen. */
public boolean hasFocus() {
return focused != null;
}
public void focus(Widget widget) {
if (focused == widget) {
return;
}
if (focused != null) {
focused.focus(false);
}
focused = widget;
if (focused != null) {
focused.focus(true);
}
}
private boolean cycleFocus(int direction) {
List<Widget> candidates = widgets.stream().filter(w -> w.active() && w.focusable()).toList();
if (candidates.isEmpty()) {
return false;
}
int current = candidates.indexOf(focused);
// With nothing focused yet, Tab starts at the first widget and
// shift-Tab at the last, rather than wherever -1 + direction lands.
int next = current < 0
? (direction > 0 ? 0 : candidates.size() - 1)
: Math.floorMod(current + direction, candidates.size());
focus(candidates.get(next));
return true;
}
}