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