asset details (WIP)
This commit is contained in:
parent
236fa04120
commit
64df57a774
@ -28,6 +28,16 @@ public class PackerTileBankWalker extends PackerAbstractBankWalker<PackerTileBan
|
||||
return SUPPORTED_MIME_TYPES;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean canReuseMetadata(
|
||||
final PackerFileProbe fileProbe,
|
||||
final PackerFileCacheEntry cachedEntry) {
|
||||
if (!super.canReuseMetadata(fileProbe, cachedEntry)) {
|
||||
return false;
|
||||
}
|
||||
return hasReusableTileMetadata(cachedEntry.metadata());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected PackerProbeResult processFileProbe(
|
||||
final PackerFileProbe fileProbe,
|
||||
@ -130,6 +140,26 @@ public class PackerTileBankWalker extends PackerAbstractBankWalker<PackerTileBan
|
||||
return new PackerProbeResult(fileProbe, Map.of("tile", tile, "palette", palette), diagnostics);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private boolean hasReusableTileMetadata(final Map<String, Object> metadata) {
|
||||
final Object tileValue = metadata.get("tile");
|
||||
if (!(tileValue instanceof Map<?, ?> tile)) {
|
||||
return false;
|
||||
}
|
||||
final Object paletteValue = metadata.get("palette");
|
||||
if (!(paletteValue instanceof Map<?, ?> palette)) {
|
||||
return false;
|
||||
}
|
||||
final Object width = tile.get("width");
|
||||
final Object height = tile.get("height");
|
||||
final Object indices = tile.get("paletteIndices");
|
||||
final Object colors = palette.get("originalArgb8888");
|
||||
return width instanceof Number
|
||||
&& height instanceof Number
|
||||
&& indices instanceof List<?>
|
||||
&& colors instanceof List<?>;
|
||||
}
|
||||
|
||||
/*
|
||||
private void serializeProbeArtifacts(
|
||||
final PackerFileProbe fileProbe,
|
||||
|
||||
@ -0,0 +1,64 @@
|
||||
package p.packer.repositories;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import p.packer.models.PackerAssetCacheEntry;
|
||||
import p.packer.models.PackerFileCacheEntry;
|
||||
import p.packer.models.PackerTileIndexedV1;
|
||||
import p.packer.models.PackerTileBankRequirements;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
final class PackerTileBankWalkerTest {
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void invalidatesCachedTileMetadataWhenPaletteIndicesAreMissing() throws Exception {
|
||||
final Path assetRoot = Files.createDirectories(tempDir.resolve("asset"));
|
||||
final Path filePath = assetRoot.resolve("tile.png");
|
||||
writeTile(filePath);
|
||||
|
||||
final PackerTileBankWalker walker = new PackerTileBankWalker(MAPPER);
|
||||
final PackerAssetCacheEntry priorCache = new PackerAssetCacheEntry(
|
||||
1,
|
||||
"contract",
|
||||
List.of(new PackerFileCacheEntry(
|
||||
"tile.png",
|
||||
"image/png",
|
||||
Files.size(filePath),
|
||||
Files.getLastModifiedTime(filePath).toMillis(),
|
||||
null,
|
||||
Map.of(
|
||||
"tile", Map.of("width", 16, "height", 16),
|
||||
"palette", Map.of("originalArgb8888", List.of(0xFFFFFFFF))),
|
||||
List.of())));
|
||||
|
||||
final var result = walker.walk(assetRoot, new PackerTileBankRequirements(16), Optional.of(priorCache));
|
||||
|
||||
final PackerTileIndexedV1 tile = (PackerTileIndexedV1) result.probeResults().getFirst().metadata().get("tile");
|
||||
assertNotNull(tile);
|
||||
assertTrue(tile.paletteIndices().length > 0);
|
||||
}
|
||||
|
||||
private void writeTile(Path path) throws Exception {
|
||||
final BufferedImage image = new BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB);
|
||||
for (int y = 0; y < 16; y++) {
|
||||
for (int x = 0; x < 16; x++) {
|
||||
image.setRGB(x, y, (x + y) % 2 == 0 ? 0xFFFF0000 : 0xFF00FF00);
|
||||
}
|
||||
}
|
||||
ImageIO.write(image, "png", path.toFile());
|
||||
}
|
||||
}
|
||||
@ -33,6 +33,8 @@ public abstract class StudioDualListView<T> extends HBox {
|
||||
|
||||
private Consumer<List<T>> onMoveToRight = ignored -> { };
|
||||
private Consumer<List<T>> onMoveToLeft = ignored -> { };
|
||||
private Consumer<T> onLeftSelectionChanged = ignored -> { };
|
||||
private Consumer<T> onRightSelectionChanged = ignored -> { };
|
||||
private IntConsumer onMoveUp = ignored -> { };
|
||||
private IntConsumer onMoveDown = ignored -> { };
|
||||
private boolean interactionEnabled = true;
|
||||
@ -95,6 +97,8 @@ public abstract class StudioDualListView<T> extends HBox {
|
||||
updateActionState();
|
||||
leftListView.getSelectionModel().getSelectedItems().addListener((javafx.collections.ListChangeListener<? super T>) ignored -> updateActionState());
|
||||
rightListView.getSelectionModel().getSelectedItems().addListener((javafx.collections.ListChangeListener<? super T>) ignored -> updateActionState());
|
||||
leftListView.getSelectionModel().selectedItemProperty().addListener((ignored, oldValue, newValue) -> onLeftSelectionChanged.accept(newValue));
|
||||
rightListView.getSelectionModel().selectedItemProperty().addListener((ignored, oldValue, newValue) -> onRightSelectionChanged.accept(newValue));
|
||||
}
|
||||
|
||||
public void setLeftItems(List<T> items) {
|
||||
@ -133,6 +137,14 @@ public abstract class StudioDualListView<T> extends HBox {
|
||||
this.onMoveToLeft = Objects.requireNonNull(onMoveToLeft, "onMoveToLeft");
|
||||
}
|
||||
|
||||
public void setOnLeftSelectionChanged(Consumer<T> onLeftSelectionChanged) {
|
||||
this.onLeftSelectionChanged = Objects.requireNonNull(onLeftSelectionChanged, "onLeftSelectionChanged");
|
||||
}
|
||||
|
||||
public void setOnRightSelectionChanged(Consumer<T> onRightSelectionChanged) {
|
||||
this.onRightSelectionChanged = Objects.requireNonNull(onRightSelectionChanged, "onRightSelectionChanged");
|
||||
}
|
||||
|
||||
public void setOnMoveUp(IntConsumer onMoveUp) {
|
||||
this.onMoveUp = Objects.requireNonNull(onMoveUp, "onMoveUp");
|
||||
}
|
||||
@ -149,6 +161,14 @@ public abstract class StudioDualListView<T> extends HBox {
|
||||
rightTitleLabel.setText(Objects.requireNonNull(title, "title"));
|
||||
}
|
||||
|
||||
public void selectLeftItem(T item) {
|
||||
selectItem(leftListView, leftItems, item);
|
||||
}
|
||||
|
||||
public void selectRightItem(T item) {
|
||||
selectItem(rightListView, rightItems, item);
|
||||
}
|
||||
|
||||
protected abstract String itemText(T item);
|
||||
|
||||
protected Node itemGraphic(T item) {
|
||||
@ -222,6 +242,22 @@ public abstract class StudioDualListView<T> extends HBox {
|
||||
}
|
||||
}
|
||||
|
||||
private void selectItem(ListView<T> listView, List<T> items, T item) {
|
||||
final var selectionModel = listView.getSelectionModel();
|
||||
if (item == null) {
|
||||
selectionModel.clearSelection();
|
||||
return;
|
||||
}
|
||||
final int index = items.indexOf(item);
|
||||
if (index < 0) {
|
||||
selectionModel.clearSelection();
|
||||
return;
|
||||
}
|
||||
selectionModel.clearSelection();
|
||||
selectionModel.select(index);
|
||||
listView.scrollTo(index);
|
||||
}
|
||||
|
||||
private Node createIndexedGraphic(Node graphic, int index) {
|
||||
final Label chip = new Label((index + 1) + ".");
|
||||
chip.getStyleClass().add("studio-dual-list-index-chip");
|
||||
|
||||
@ -97,6 +97,7 @@ public enum I18n {
|
||||
ASSETS_SECTION_SUMMARY("assets.section.summary"),
|
||||
ASSETS_SECTION_RUNTIME_CONTRACT("assets.section.runtimeContract"),
|
||||
ASSETS_SECTION_BANK_COMPOSITION("assets.section.bankComposition"),
|
||||
ASSETS_SECTION_PALETTE_OVERHAULING("assets.section.paletteOverhauling"),
|
||||
ASSETS_SUBSECTION_CODEC_CONFIGURATION("assets.subsection.codecConfiguration"),
|
||||
ASSETS_SUBSECTION_METADATA("assets.subsection.metadata"),
|
||||
ASSETS_SECTION_INPUTS_PREVIEW("assets.section.inputsPreview"),
|
||||
@ -187,6 +188,11 @@ public enum I18n {
|
||||
ASSETS_DETAILS_BANK_COMPOSITION_SELECTED("assets.details.bankComposition.selected"),
|
||||
ASSETS_DETAILS_BANK_COMPOSITION_READONLY_HINT("assets.details.bankComposition.readonlyHint"),
|
||||
ASSETS_DETAILS_BANK_COMPOSITION_EDITING_HINT("assets.details.bankComposition.editingHint"),
|
||||
ASSETS_PALETTE_OVERHAULING_AVAILABLE("assets.paletteOverhauling.available"),
|
||||
ASSETS_PALETTE_OVERHAULING_SELECTED("assets.paletteOverhauling.selected"),
|
||||
ASSETS_PALETTE_OVERHAULING_TILE_SELECTOR("assets.paletteOverhauling.tileSelector"),
|
||||
ASSETS_PALETTE_OVERHAULING_APPLIED_PALETTE("assets.paletteOverhauling.appliedPalette"),
|
||||
ASSETS_PALETTE_OVERHAULING_PREVIEW_EMPTY("assets.paletteOverhauling.previewEmpty"),
|
||||
ASSETS_DETAILS_CODEC_CONFIGURATION_EMPTY("assets.details.codecConfiguration.empty"),
|
||||
ASSETS_DETAILS_METADATA_EMPTY("assets.details.metadata.empty"),
|
||||
ASSETS_ADD_WIZARD_TITLE("assets.addWizard.title"),
|
||||
|
||||
@ -21,6 +21,7 @@ import p.studio.utilities.i18n.I18n;
|
||||
import p.studio.workspaces.assets.dialogs.AssetDiagnosticsDialog;
|
||||
import p.studio.workspaces.assets.details.bank.AssetDetailsBankCompositionControl;
|
||||
import p.studio.workspaces.assets.details.contract.AssetDetailsContractControl;
|
||||
import p.studio.workspaces.assets.details.palette.AssetDetailsPaletteOverhaulingControl;
|
||||
import p.studio.workspaces.assets.details.summary.AssetDetailsSummaryControl;
|
||||
import p.studio.workspaces.assets.messages.AssetWorkspaceAssetAction;
|
||||
import p.studio.workspaces.assets.messages.AssetWorkspaceBankCompositionDetails;
|
||||
@ -52,6 +53,7 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
|
||||
private final AssetDetailsSummaryControl summaryControl;
|
||||
private final AssetDetailsContractControl contractControl;
|
||||
private final AssetDetailsBankCompositionControl bankCompositionControl;
|
||||
private final AssetDetailsPaletteOverhaulingControl paletteOverhaulingControl;
|
||||
private final VBox actionsContent = new VBox(10);
|
||||
private final ScrollPane actionsScroll = new ScrollPane();
|
||||
private final VBox actionsSection;
|
||||
@ -71,6 +73,7 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
|
||||
this.summaryControl = new AssetDetailsSummaryControl(projectReference, workspaceBus);
|
||||
this.contractControl = new AssetDetailsContractControl(projectReference, workspaceBus);
|
||||
this.bankCompositionControl = new AssetDetailsBankCompositionControl(projectReference, workspaceBus);
|
||||
this.paletteOverhaulingControl = new AssetDetailsPaletteOverhaulingControl(workspaceBus);
|
||||
this.actionsSection = createActionsSection();
|
||||
|
||||
getStyleClass().add("assets-workspace-pane");
|
||||
@ -254,7 +257,7 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
|
||||
renderActions();
|
||||
if (!readyMounted) {
|
||||
readyMounted = true;
|
||||
detailsContent.getChildren().setAll(primarySectionsRow, contractControl, bankCompositionControl);
|
||||
detailsContent.getChildren().setAll(primarySectionsRow, contractControl, bankCompositionControl, paletteOverhaulingControl);
|
||||
}
|
||||
syncActionsSectionHeight(summaryControl.getHeight());
|
||||
}
|
||||
|
||||
@ -0,0 +1,183 @@
|
||||
package p.studio.workspaces.assets.details.palette;
|
||||
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import p.studio.Container;
|
||||
import p.studio.controls.forms.StudioFormEditScopeChangedEvent;
|
||||
import p.studio.controls.forms.StudioFormMode;
|
||||
import p.studio.controls.forms.StudioFormSection;
|
||||
import p.studio.events.StudioWorkspaceEventBus;
|
||||
import p.studio.utilities.i18n.I18n;
|
||||
import p.studio.workspaces.assets.messages.AssetWorkspaceDetailsViewState;
|
||||
import p.studio.workspaces.assets.messages.events.StudioAssetsDetailsViewStateChangedEvent;
|
||||
import p.studio.workspaces.framework.StudioSubscriptionBag;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public final class AssetDetailsPaletteOverhaulingControl extends StudioFormSection {
|
||||
private static final String SECTION_ID = "asset-details.palette-overhauling";
|
||||
|
||||
private final StudioWorkspaceEventBus workspaceBus;
|
||||
private final StudioSubscriptionBag subscriptions = new StudioSubscriptionBag();
|
||||
private final AssetDetailsPaletteOverhaulingCoordinator coordinator = new AssetDetailsPaletteOverhaulingCoordinator();
|
||||
private final AssetDetailsPaletteOverhaulingDualListView dualListView = new AssetDetailsPaletteOverhaulingDualListView();
|
||||
private final AssetDetailsPaletteOverhaulingPreviewPane previewPane = new AssetDetailsPaletteOverhaulingPreviewPane();
|
||||
private final HBox body = new HBox(16, dualListView, previewPane);
|
||||
private final VBox content = new VBox(12, body);
|
||||
|
||||
private AssetWorkspaceDetailsViewState viewState;
|
||||
|
||||
public AssetDetailsPaletteOverhaulingControl(StudioWorkspaceEventBus workspaceBus) {
|
||||
this.workspaceBus = Objects.requireNonNull(workspaceBus, "workspaceBus");
|
||||
body.getStyleClass().add("assets-details-palette-overhauling-body");
|
||||
content.setFillWidth(true);
|
||||
HBox.setHgrow(dualListView, Priority.ALWAYS);
|
||||
HBox.setHgrow(previewPane, Priority.ALWAYS);
|
||||
dualListView.setMaxWidth(Double.MAX_VALUE);
|
||||
previewPane.setMaxWidth(Double.MAX_VALUE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void subscribe() {
|
||||
subscriptions.add(workspaceBus.subscribe(StudioAssetsDetailsViewStateChangedEvent.class, event -> {
|
||||
viewState = event.viewState();
|
||||
coordinator.replaceDetails(viewState == null ? null : viewState.selectedAssetDetails());
|
||||
if (viewState == null || viewState.selectedAssetReference() == null) {
|
||||
publishEditScope(workspaceBus, currentScopeKey(), null);
|
||||
}
|
||||
renderSection();
|
||||
}));
|
||||
subscriptions.add(workspaceBus.subscribe(StudioFormEditScopeChangedEvent.class,
|
||||
event -> handleEditScopeChanged(event, currentScopeKey())));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void unsubscribe() {
|
||||
subscriptions.clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void renderSection() {
|
||||
if (viewState == null || viewState.selectedAssetDetails() == null || !coordinator.ready()) {
|
||||
clearRenderedSection();
|
||||
return;
|
||||
}
|
||||
|
||||
final AssetDetailsPaletteOverhaulingViewModel viewModel = coordinator.viewModel();
|
||||
dualListView.setLeftItems(viewModel.availableFiles());
|
||||
dualListView.setRightItems(viewModel.selectedFiles());
|
||||
dualListView.setInteractionEnabled(viewModel.editing());
|
||||
dualListView.setMoveToRightAllowed(viewModel.selectedFiles().size() < 64);
|
||||
dualListView.setOnMoveToRight(items -> {
|
||||
coordinator.moveToSelected(items);
|
||||
rerenderPreservingScrollPosition();
|
||||
});
|
||||
dualListView.setOnMoveToLeft(items -> {
|
||||
coordinator.moveToAvailable(items);
|
||||
rerenderPreservingScrollPosition();
|
||||
});
|
||||
dualListView.setOnMoveUp(index -> { });
|
||||
dualListView.setOnMoveDown(index -> { });
|
||||
dualListView.setOnLeftSelectionChanged(item -> {
|
||||
if (item != null) {
|
||||
coordinator.selectPreviewPalette(item);
|
||||
previewPane.render(
|
||||
coordinator.viewModel().previewTileFiles(),
|
||||
coordinator.viewModel().previewTileFile(),
|
||||
coordinator.viewModel().previewPaletteFile(),
|
||||
coordinator.viewModel().renderableFileCount());
|
||||
}
|
||||
});
|
||||
dualListView.setOnRightSelectionChanged(item -> {
|
||||
if (item != null) {
|
||||
coordinator.selectPreviewPalette(item);
|
||||
previewPane.render(
|
||||
coordinator.viewModel().previewTileFiles(),
|
||||
coordinator.viewModel().previewTileFile(),
|
||||
coordinator.viewModel().previewPaletteFile(),
|
||||
coordinator.viewModel().renderableFileCount());
|
||||
}
|
||||
});
|
||||
if (viewModel.previewPaletteFile() != null) {
|
||||
if (viewModel.selectedFiles().contains(viewModel.previewPaletteFile())) {
|
||||
dualListView.selectRightItem(viewModel.previewPaletteFile());
|
||||
} else {
|
||||
dualListView.selectLeftItem(viewModel.previewPaletteFile());
|
||||
}
|
||||
}
|
||||
|
||||
previewPane.setOnPreviewTileChanged(file -> {
|
||||
if (file != null) {
|
||||
coordinator.selectPreviewTile(file);
|
||||
previewPane.render(
|
||||
coordinator.viewModel().previewTileFiles(),
|
||||
coordinator.viewModel().previewTileFile(),
|
||||
coordinator.viewModel().previewPaletteFile(),
|
||||
coordinator.viewModel().renderableFileCount());
|
||||
}
|
||||
});
|
||||
previewPane.render(
|
||||
viewModel.previewTileFiles(),
|
||||
viewModel.previewTileFile(),
|
||||
viewModel.previewPaletteFile(),
|
||||
viewModel.renderableFileCount());
|
||||
|
||||
renderFormSection(content);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String sectionTitle() {
|
||||
return Container.i18n().text(I18n.ASSETS_SECTION_PALETTE_OVERHAULING);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected StudioFormMode formMode() {
|
||||
return coordinator.ready() && coordinator.viewModel().editing() ? StudioFormMode.EDITING : StudioFormMode.READ_ONLY;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean isDirty() {
|
||||
return coordinator.ready() && coordinator.viewModel().dirty();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void beginEdit() {
|
||||
coordinator.beginEdit();
|
||||
publishEditScope(workspaceBus, currentScopeKey(), SECTION_ID);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void apply() {
|
||||
coordinator.apply();
|
||||
publishEditScope(workspaceBus, currentScopeKey(), null);
|
||||
renderSection();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reset() {
|
||||
coordinator.reset();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void cancel() {
|
||||
coordinator.cancel();
|
||||
publishEditScope(workspaceBus, currentScopeKey(), null);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String sectionStyleClass() {
|
||||
return "assets-details-palette-overhauling-section";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String formSectionId() {
|
||||
return SECTION_ID;
|
||||
}
|
||||
|
||||
private String currentScopeKey() {
|
||||
return viewState == null || viewState.selectedAssetReference() == null
|
||||
? null
|
||||
: "asset-details:" + viewState.selectedAssetReference();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,209 @@
|
||||
package p.studio.workspaces.assets.details.palette;
|
||||
|
||||
import p.packer.messages.assets.AssetFamilyCatalog;
|
||||
import p.studio.controls.forms.StudioFormMode;
|
||||
import p.studio.controls.forms.StudioFormSession;
|
||||
import p.studio.workspaces.assets.messages.AssetWorkspaceAssetDetails;
|
||||
import p.studio.workspaces.assets.messages.AssetWorkspaceBankCompositionFile;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class AssetDetailsPaletteOverhaulingCoordinator {
|
||||
private StudioFormSession<AssetDetailsPaletteOverhaulingDraft> formSession;
|
||||
|
||||
public void replaceDetails(AssetWorkspaceAssetDetails details) {
|
||||
if (details == null || details.summary().assetFamily() != AssetFamilyCatalog.TILE_BANK) {
|
||||
formSession = null;
|
||||
return;
|
||||
}
|
||||
|
||||
final List<AssetWorkspaceBankCompositionFile> allFiles = paletteFiles(details);
|
||||
final List<AssetWorkspaceBankCompositionFile> previewTileFiles = details.bankComposition().selectedFiles().stream()
|
||||
.filter(AssetDetailsPaletteOverhaulingCoordinator::supportsPalettePreview)
|
||||
.toList();
|
||||
final Map<String, AssetWorkspaceBankCompositionFile> filesByPath = allFiles.stream()
|
||||
.collect(java.util.stream.Collectors.toMap(
|
||||
AssetWorkspaceBankCompositionFile::path,
|
||||
file -> file,
|
||||
(left, right) -> left,
|
||||
LinkedHashMap::new));
|
||||
final List<AssetWorkspaceBankCompositionFile> initialSelected = new ArrayList<>();
|
||||
|
||||
final List<AssetWorkspaceBankCompositionFile> available = new ArrayList<>(allFiles);
|
||||
available.removeAll(initialSelected);
|
||||
final AssetDetailsPaletteOverhaulingDraft source = new AssetDetailsPaletteOverhaulingDraft(
|
||||
previewTileFiles,
|
||||
allFiles,
|
||||
available,
|
||||
initialSelected,
|
||||
previewTileFiles.isEmpty() ? null : previewTileFiles.getFirst().path(),
|
||||
null);
|
||||
if (formSession == null) {
|
||||
formSession = new StudioFormSession<>(source);
|
||||
return;
|
||||
}
|
||||
if (!Objects.equals(formSession.source(), source)) {
|
||||
formSession.replaceSource(source);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean ready() {
|
||||
return formSession != null;
|
||||
}
|
||||
|
||||
public void beginEdit() {
|
||||
if (ready()) {
|
||||
formSession.beginEdit();
|
||||
}
|
||||
}
|
||||
|
||||
public void apply() {
|
||||
if (ready()) {
|
||||
formSession.apply();
|
||||
}
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
if (ready()) {
|
||||
formSession.resetDraft();
|
||||
}
|
||||
}
|
||||
|
||||
public void cancel() {
|
||||
if (ready()) {
|
||||
formSession.cancelEdit();
|
||||
}
|
||||
}
|
||||
|
||||
public void moveToSelected(List<AssetWorkspaceBankCompositionFile> files) {
|
||||
if (ready() && formSession.mode() == StudioFormMode.EDITING) {
|
||||
formSession.updateDraft(current -> current.moveToSelected(files));
|
||||
}
|
||||
}
|
||||
|
||||
public void moveToAvailable(List<AssetWorkspaceBankCompositionFile> files) {
|
||||
if (ready() && formSession.mode() == StudioFormMode.EDITING) {
|
||||
formSession.updateDraft(current -> current.moveToAvailable(files));
|
||||
}
|
||||
}
|
||||
|
||||
public void selectPreviewTile(AssetWorkspaceBankCompositionFile file) {
|
||||
if (ready() && file != null) {
|
||||
formSession.updateDraft(current -> current.selectPreviewTile(file.path()));
|
||||
}
|
||||
}
|
||||
|
||||
public void selectPreviewPalette(AssetWorkspaceBankCompositionFile file) {
|
||||
if (ready() && file != null) {
|
||||
formSession.updateDraft(current -> current.selectPreviewPalette(file.path()));
|
||||
}
|
||||
}
|
||||
|
||||
public AssetDetailsPaletteOverhaulingViewModel viewModel() {
|
||||
if (!ready()) {
|
||||
return new AssetDetailsPaletteOverhaulingViewModel(false, false, List.of(), List.of(), List.of(), List.of(), null, null, 0);
|
||||
}
|
||||
|
||||
final AssetDetailsPaletteOverhaulingDraft draft = formSession.draft();
|
||||
return new AssetDetailsPaletteOverhaulingViewModel(
|
||||
formSession.mode() == StudioFormMode.EDITING,
|
||||
formSession.isDirty(),
|
||||
draft.previewTileFiles(),
|
||||
draft.allFiles(),
|
||||
draft.availableFiles(),
|
||||
draft.selectedFiles(),
|
||||
fileByPath(draft.previewTileFiles(), draft.previewTilePath()),
|
||||
fileByPath(draft.allFiles(), draft.previewPalettePath()),
|
||||
draft.allFiles().size());
|
||||
}
|
||||
|
||||
static boolean supportsPalettePreview(AssetWorkspaceBankCompositionFile file) {
|
||||
if (file == null) {
|
||||
return false;
|
||||
}
|
||||
return nestedMap(file.metadata(), "tile") != null && nestedMap(file.metadata(), "palette") != null;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, Object> nestedMap(Map<String, Object> metadata, String key) {
|
||||
final Object value = metadata.get(key);
|
||||
if (value instanceof Map<?, ?> nested) {
|
||||
return (Map<String, Object>) nested;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<AssetWorkspaceBankCompositionFile> paletteFiles(AssetWorkspaceAssetDetails details) {
|
||||
final Map<String, AssetWorkspaceBankCompositionFile> ordered = new LinkedHashMap<>();
|
||||
// `bankComposition` files are already projected by the packer read model from the current cache/walk state.
|
||||
for (AssetWorkspaceBankCompositionFile file : details.bankComposition().availableFiles()) {
|
||||
if (supportsPalettePreview(file)) {
|
||||
ordered.putIfAbsent(paletteHash(file), canonicalPaletteFile(file));
|
||||
}
|
||||
}
|
||||
for (AssetWorkspaceBankCompositionFile file : details.bankComposition().selectedFiles()) {
|
||||
if (supportsPalettePreview(file)) {
|
||||
ordered.putIfAbsent(paletteHash(file), canonicalPaletteFile(file));
|
||||
}
|
||||
}
|
||||
return List.copyOf(ordered.values());
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static AssetWorkspaceBankCompositionFile canonicalPaletteFile(AssetWorkspaceBankCompositionFile file) {
|
||||
final String hash = paletteHash(file);
|
||||
final Map<String, Object> metadata = new LinkedHashMap<>(file.metadata());
|
||||
metadata.put("paletteHash", hash);
|
||||
return new AssetWorkspaceBankCompositionFile(
|
||||
hash,
|
||||
hash,
|
||||
file.size(),
|
||||
file.lastModified(),
|
||||
file.fingerprint(),
|
||||
metadata);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static String paletteHash(AssetWorkspaceBankCompositionFile file) {
|
||||
final Map<String, Object> palette = nestedMap(file.metadata(), "palette");
|
||||
final Object convertedValue = palette == null ? null : palette.get("convertedRgb565");
|
||||
final List<Integer> colors = convertedValue instanceof List<?> converted
|
||||
? (List<Integer>) converted
|
||||
: List.of();
|
||||
try {
|
||||
final MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
for (int index = 0; index < 15; index++) {
|
||||
final int color = index < colors.size() && colors.get(index) != null ? colors.get(index) : 0;
|
||||
digest.update(ByteBuffer.allocate(4).putInt(color).array());
|
||||
}
|
||||
long value = 0L;
|
||||
final byte[] bytes = digest.digest();
|
||||
for (int index = 0; index < 8; index++) {
|
||||
value = (value << 8) | Byte.toUnsignedLong(bytes[index]);
|
||||
}
|
||||
value &= 0x0FFFFFFFFFFFFFFFL;
|
||||
final String base36 = Long.toUnsignedString(value, 36).toUpperCase();
|
||||
return base36.length() >= 10
|
||||
? base36.substring(0, 10)
|
||||
: "0".repeat(10 - base36.length()) + base36;
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("Unable to hash palette colors", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static AssetWorkspaceBankCompositionFile fileByPath(List<AssetWorkspaceBankCompositionFile> files, String path) {
|
||||
if (path == null) {
|
||||
return null;
|
||||
}
|
||||
return files.stream()
|
||||
.filter(file -> path.equals(file.path()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,95 @@
|
||||
package p.studio.workspaces.assets.details.palette;
|
||||
|
||||
import p.studio.workspaces.assets.messages.AssetWorkspaceBankCompositionFile;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public record AssetDetailsPaletteOverhaulingDraft(
|
||||
List<AssetWorkspaceBankCompositionFile> previewTileFiles,
|
||||
List<AssetWorkspaceBankCompositionFile> allFiles,
|
||||
List<AssetWorkspaceBankCompositionFile> availableFiles,
|
||||
List<AssetWorkspaceBankCompositionFile> selectedFiles,
|
||||
String previewTilePath,
|
||||
String previewPalettePath) {
|
||||
private static final int MIN_SELECTED = 1;
|
||||
private static final int MAX_SELECTED = 64;
|
||||
|
||||
public AssetDetailsPaletteOverhaulingDraft {
|
||||
previewTileFiles = List.copyOf(Objects.requireNonNull(previewTileFiles, "previewTileFiles"));
|
||||
allFiles = List.copyOf(Objects.requireNonNull(allFiles, "allFiles"));
|
||||
availableFiles = List.copyOf(Objects.requireNonNull(availableFiles, "availableFiles"));
|
||||
selectedFiles = List.copyOf(Objects.requireNonNull(selectedFiles, "selectedFiles"));
|
||||
previewTilePath = normalize(previewTilePath);
|
||||
previewPalettePath = normalize(previewPalettePath);
|
||||
}
|
||||
|
||||
public AssetDetailsPaletteOverhaulingDraft moveToSelected(List<AssetWorkspaceBankCompositionFile> files) {
|
||||
final List<AssetWorkspaceBankCompositionFile> accepted = new ArrayList<>();
|
||||
for (AssetWorkspaceBankCompositionFile file : files) {
|
||||
if (file == null || !availableFiles.contains(file) || selectedFiles.contains(file)) {
|
||||
continue;
|
||||
}
|
||||
if (selectedFiles.size() + accepted.size() >= MAX_SELECTED) {
|
||||
break;
|
||||
}
|
||||
accepted.add(file);
|
||||
}
|
||||
if (accepted.isEmpty()) {
|
||||
return this;
|
||||
}
|
||||
|
||||
final List<AssetWorkspaceBankCompositionFile> nextAvailable = new ArrayList<>(availableFiles);
|
||||
nextAvailable.removeAll(accepted);
|
||||
final List<AssetWorkspaceBankCompositionFile> nextSelected = new ArrayList<>(selectedFiles);
|
||||
nextSelected.addAll(accepted);
|
||||
final String nextPreviewPalettePath = previewPalettePath == null ? accepted.getFirst().path() : previewPalettePath;
|
||||
return new AssetDetailsPaletteOverhaulingDraft(previewTileFiles, allFiles, nextAvailable, nextSelected, previewTilePath, nextPreviewPalettePath);
|
||||
}
|
||||
|
||||
public AssetDetailsPaletteOverhaulingDraft moveToAvailable(List<AssetWorkspaceBankCompositionFile> files) {
|
||||
final List<AssetWorkspaceBankCompositionFile> removed = new ArrayList<>();
|
||||
for (AssetWorkspaceBankCompositionFile file : files) {
|
||||
if (file != null && selectedFiles.contains(file)) {
|
||||
removed.add(file);
|
||||
}
|
||||
}
|
||||
if (removed.isEmpty() || selectedFiles.size() - removed.size() < MIN_SELECTED) {
|
||||
return this;
|
||||
}
|
||||
|
||||
final List<AssetWorkspaceBankCompositionFile> nextSelected = new ArrayList<>(selectedFiles);
|
||||
nextSelected.removeAll(removed);
|
||||
final List<AssetWorkspaceBankCompositionFile> nextAvailable = new ArrayList<>(availableFiles);
|
||||
nextAvailable.addAll(removed);
|
||||
final String nextPreviewPalettePath = removed.stream().anyMatch(file -> file.path().equals(previewPalettePath))
|
||||
? nextSelected.getFirst().path()
|
||||
: previewPalettePath;
|
||||
return new AssetDetailsPaletteOverhaulingDraft(previewTileFiles, allFiles, nextAvailable, nextSelected, previewTilePath, nextPreviewPalettePath);
|
||||
}
|
||||
|
||||
public AssetDetailsPaletteOverhaulingDraft selectPreviewTile(String path) {
|
||||
final String normalized = normalize(path);
|
||||
if (normalized == null || previewTileFiles.stream().noneMatch(file -> file.path().equals(normalized))) {
|
||||
return this;
|
||||
}
|
||||
return new AssetDetailsPaletteOverhaulingDraft(previewTileFiles, allFiles, availableFiles, selectedFiles, normalized, previewPalettePath);
|
||||
}
|
||||
|
||||
public AssetDetailsPaletteOverhaulingDraft selectPreviewPalette(String path) {
|
||||
final String normalized = normalize(path);
|
||||
if (normalized == null || allFiles.stream().noneMatch(file -> file.path().equals(normalized))) {
|
||||
return this;
|
||||
}
|
||||
return new AssetDetailsPaletteOverhaulingDraft(previewTileFiles, allFiles, availableFiles, selectedFiles, previewTilePath, normalized);
|
||||
}
|
||||
|
||||
private static String normalize(String path) {
|
||||
if (path == null) {
|
||||
return null;
|
||||
}
|
||||
final String trimmed = path.trim();
|
||||
return trimmed.isBlank() ? null : trimmed;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,96 @@
|
||||
package p.studio.workspaces.assets.details.palette;
|
||||
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Region;
|
||||
import p.studio.Container;
|
||||
import p.studio.controls.banks.StudioDualListView;
|
||||
import p.studio.utilities.i18n.I18n;
|
||||
import p.studio.workspaces.assets.messages.AssetWorkspaceBankCompositionFile;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class AssetDetailsPaletteOverhaulingDualListView extends StudioDualListView<AssetWorkspaceBankCompositionFile> {
|
||||
public AssetDetailsPaletteOverhaulingDualListView() {
|
||||
setLeftTitle(Container.i18n().text(I18n.ASSETS_PALETTE_OVERHAULING_AVAILABLE));
|
||||
setRightTitle(Container.i18n().text(I18n.ASSETS_PALETTE_OVERHAULING_SELECTED));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String itemText(AssetWorkspaceBankCompositionFile item) {
|
||||
return "";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Node itemGraphic(AssetWorkspaceBankCompositionFile item) {
|
||||
final Label hashLabel = new Label(item.displayName());
|
||||
hashLabel.getStyleClass().add("assets-details-palette-hash");
|
||||
final HBox strip = createPaletteStrip(item);
|
||||
final HBox root = new HBox(8, hashLabel, strip);
|
||||
root.getStyleClass().add("assets-details-palette-item");
|
||||
return root;
|
||||
}
|
||||
|
||||
private HBox createPaletteStrip(AssetWorkspaceBankCompositionFile item) {
|
||||
final HBox strip = new HBox(0);
|
||||
strip.getStyleClass().add("assets-details-palette-strip");
|
||||
final List<Integer> colors = paletteDisplayColors(item.metadata());
|
||||
for (int index = 0; index < 15; index++) {
|
||||
final Region swatch = new Region();
|
||||
swatch.getStyleClass().add("assets-details-palette-swatch");
|
||||
swatch.setMinSize(12.0d, 18.0d);
|
||||
swatch.setPrefSize(12.0d, 18.0d);
|
||||
swatch.setMaxSize(12.0d, 18.0d);
|
||||
swatch.setStyle("-fx-background-color: " + cssColor(colorAt(colors, index)) + ";");
|
||||
strip.getChildren().add(swatch);
|
||||
}
|
||||
return strip;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Integer> paletteDisplayColors(Map<String, Object> metadata) {
|
||||
final Object paletteValue = metadata.get("palette");
|
||||
if (!(paletteValue instanceof Map<?, ?> palette)) {
|
||||
return List.of();
|
||||
}
|
||||
final Object convertedValue = palette.get("convertedRgb565");
|
||||
if (convertedValue instanceof List<?> converted) {
|
||||
return ((List<Integer>) converted).stream()
|
||||
.map(this::rgb565ToArgb8888)
|
||||
.toList();
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private int colorAt(List<Integer> colors, int index) {
|
||||
if (index < 0 || index >= colors.size()) {
|
||||
return 0x00000000;
|
||||
}
|
||||
final Integer color = colors.get(index);
|
||||
return color == null ? 0x00000000 : color;
|
||||
}
|
||||
|
||||
private String cssColor(int argb) {
|
||||
final int alpha = (argb >>> 24) & 0xFF;
|
||||
if (alpha == 0) {
|
||||
return "transparent";
|
||||
}
|
||||
final int red = (argb >>> 16) & 0xFF;
|
||||
final int green = (argb >>> 8) & 0xFF;
|
||||
final int blue = argb & 0xFF;
|
||||
return String.format("#%02X%02X%02X", red, green, blue);
|
||||
}
|
||||
|
||||
private int rgb565ToArgb8888(Integer rgb565) {
|
||||
if (rgb565 == null) {
|
||||
return 0x00000000;
|
||||
}
|
||||
final int value = rgb565 & 0xFFFF;
|
||||
final int red = ((value >> 11) & 0x1F) * 255 / 31;
|
||||
final int green = ((value >> 5) & 0x3F) * 255 / 63;
|
||||
final int blue = (value & 0x1F) * 255 / 31;
|
||||
return 0xFF000000 | (red << 16) | (green << 8) | blue;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,263 @@
|
||||
package p.studio.workspaces.assets.details.palette;
|
||||
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.canvas.Canvas;
|
||||
import javafx.scene.canvas.GraphicsContext;
|
||||
import javafx.scene.control.ComboBox;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.VBox;
|
||||
import p.studio.Container;
|
||||
import p.studio.utilities.i18n.I18n;
|
||||
import p.studio.workspaces.assets.messages.AssetWorkspaceBankCompositionFile;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public final class AssetDetailsPaletteOverhaulingPreviewPane extends VBox {
|
||||
private static final double PREVIEW_SIZE = 192.0d;
|
||||
private static final double PREVIEW_FRAME_SIZE = PREVIEW_SIZE + 16.0d;
|
||||
|
||||
private final Label selectorLabel = new Label(Container.i18n().text(I18n.ASSETS_PALETTE_OVERHAULING_TILE_SELECTOR));
|
||||
private final ComboBox<AssetWorkspaceBankCompositionFile> tileSelector = new ComboBox<>();
|
||||
private final Canvas tileCanvas = new Canvas(PREVIEW_SIZE, PREVIEW_SIZE);
|
||||
private final Label emptyLabel = new Label(Container.i18n().text(I18n.ASSETS_PALETTE_OVERHAULING_PREVIEW_EMPTY));
|
||||
private final Label paletteLabel = new Label(Container.i18n().text(I18n.ASSETS_PALETTE_OVERHAULING_APPLIED_PALETTE));
|
||||
private final HBox paletteStrip = new HBox(0);
|
||||
|
||||
private Consumer<AssetWorkspaceBankCompositionFile> onPreviewTileChanged = ignored -> { };
|
||||
private boolean suppressSelectorEvents;
|
||||
|
||||
public AssetDetailsPaletteOverhaulingPreviewPane() {
|
||||
getStyleClass().add("assets-details-palette-preview");
|
||||
setSpacing(10);
|
||||
setAlignment(Pos.TOP_CENTER);
|
||||
setMinWidth(PREVIEW_FRAME_SIZE);
|
||||
setPrefWidth(PREVIEW_FRAME_SIZE);
|
||||
setMaxWidth(PREVIEW_FRAME_SIZE);
|
||||
|
||||
selectorLabel.getStyleClass().add("assets-details-palette-preview-label");
|
||||
paletteLabel.getStyleClass().add("assets-details-palette-preview-label");
|
||||
selectorLabel.setMaxWidth(Double.MAX_VALUE);
|
||||
selectorLabel.setAlignment(Pos.CENTER_LEFT);
|
||||
paletteLabel.setMaxWidth(Double.MAX_VALUE);
|
||||
paletteLabel.setAlignment(Pos.CENTER);
|
||||
|
||||
tileSelector.setMinWidth(PREVIEW_FRAME_SIZE);
|
||||
tileSelector.setPrefWidth(PREVIEW_FRAME_SIZE);
|
||||
tileSelector.setMaxWidth(PREVIEW_FRAME_SIZE);
|
||||
tileSelector.setCellFactory(ignored -> new javafx.scene.control.ListCell<>() {
|
||||
@Override
|
||||
protected void updateItem(AssetWorkspaceBankCompositionFile item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
setText(empty || item == null ? null : item.displayName());
|
||||
}
|
||||
});
|
||||
tileSelector.setButtonCell(new javafx.scene.control.ListCell<>() {
|
||||
@Override
|
||||
protected void updateItem(AssetWorkspaceBankCompositionFile item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
setText(empty || item == null ? null : item.displayName());
|
||||
}
|
||||
});
|
||||
tileSelector.valueProperty().addListener((ignored, oldValue, newValue) -> {
|
||||
if (!suppressSelectorEvents && newValue != null) {
|
||||
onPreviewTileChanged.accept(newValue);
|
||||
}
|
||||
});
|
||||
|
||||
final VBox previewSurface = new VBox(emptyLabel, tileCanvas);
|
||||
previewSurface.getStyleClass().add("assets-details-palette-preview-surface");
|
||||
previewSurface.setAlignment(Pos.CENTER);
|
||||
previewSurface.setMinWidth(PREVIEW_FRAME_SIZE);
|
||||
previewSurface.setPrefWidth(PREVIEW_FRAME_SIZE);
|
||||
previewSurface.setMaxWidth(PREVIEW_FRAME_SIZE);
|
||||
previewSurface.setMinHeight(PREVIEW_FRAME_SIZE);
|
||||
previewSurface.setPrefHeight(PREVIEW_FRAME_SIZE);
|
||||
previewSurface.setMaxHeight(PREVIEW_FRAME_SIZE);
|
||||
VBox.setVgrow(previewSurface, Priority.ALWAYS);
|
||||
emptyLabel.getStyleClass().add("assets-details-section-message");
|
||||
|
||||
paletteStrip.getStyleClass().add("assets-details-palette-preview-strip");
|
||||
paletteStrip.setAlignment(Pos.CENTER);
|
||||
paletteStrip.setMinWidth(PREVIEW_FRAME_SIZE);
|
||||
paletteStrip.setPrefWidth(PREVIEW_FRAME_SIZE);
|
||||
paletteStrip.setMaxWidth(PREVIEW_FRAME_SIZE);
|
||||
for (int index = 0; index < 15; index++) {
|
||||
final Region swatch = new Region();
|
||||
swatch.getStyleClass().add("assets-details-palette-preview-swatch");
|
||||
swatch.setMinSize(16.0d, 26.0d);
|
||||
swatch.setPrefSize(16.0d, 26.0d);
|
||||
swatch.setMaxSize(16.0d, 26.0d);
|
||||
paletteStrip.getChildren().add(swatch);
|
||||
}
|
||||
|
||||
getChildren().addAll(selectorLabel, tileSelector, previewSurface, paletteLabel, paletteStrip);
|
||||
}
|
||||
|
||||
public void setOnPreviewTileChanged(Consumer<AssetWorkspaceBankCompositionFile> onPreviewTileChanged) {
|
||||
this.onPreviewTileChanged = Objects.requireNonNull(onPreviewTileChanged, "onPreviewTileChanged");
|
||||
}
|
||||
|
||||
public void render(
|
||||
List<AssetWorkspaceBankCompositionFile> previewFiles,
|
||||
AssetWorkspaceBankCompositionFile previewTileFile,
|
||||
AssetWorkspaceBankCompositionFile previewPaletteFile,
|
||||
int renderableFileCount) {
|
||||
suppressSelectorEvents = true;
|
||||
tileSelector.setItems(FXCollections.observableArrayList(previewFiles));
|
||||
tileSelector.setValue(previewTileFile);
|
||||
suppressSelectorEvents = false;
|
||||
|
||||
final boolean renderable = previewTileFile != null && previewPaletteFile != null;
|
||||
tileCanvas.setVisible(renderable);
|
||||
tileCanvas.setManaged(renderable);
|
||||
emptyLabel.setVisible(!renderable);
|
||||
emptyLabel.setManaged(!renderable);
|
||||
updatePaletteStrip(previewPaletteFile);
|
||||
if (renderable) {
|
||||
renderTile(tileCanvas.getGraphicsContext2D(), previewTileFile, previewPaletteFile);
|
||||
} else {
|
||||
clearCanvas(tileCanvas.getGraphicsContext2D());
|
||||
}
|
||||
}
|
||||
|
||||
private void updatePaletteStrip(AssetWorkspaceBankCompositionFile previewPaletteFile) {
|
||||
final List<Integer> colors = previewPaletteFile == null ? List.of() : paletteDisplayColors(previewPaletteFile.metadata());
|
||||
for (int index = 0; index < paletteStrip.getChildren().size(); index++) {
|
||||
final Region swatch = (Region) paletteStrip.getChildren().get(index);
|
||||
final int argb = colorAt(colors, index);
|
||||
swatch.setStyle("-fx-background-color: " + cssColor(argb) + ";");
|
||||
}
|
||||
}
|
||||
|
||||
private void renderTile(GraphicsContext graphics, AssetWorkspaceBankCompositionFile tileFile, AssetWorkspaceBankCompositionFile paletteFile) {
|
||||
clearCanvas(graphics);
|
||||
final Map<String, Object> tile = nestedMap(tileFile.metadata(), "tile");
|
||||
if (tile == null) {
|
||||
return;
|
||||
}
|
||||
final Integer width = asInteger(tile.get("width"));
|
||||
final Integer height = asInteger(tile.get("height"));
|
||||
final List<Integer> indices = paletteIndices(tile.get("paletteIndices"));
|
||||
final List<Integer> colors = paletteDisplayColors(paletteFile.metadata());
|
||||
if (width == null || height == null || width <= 0 || height <= 0 || indices.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final double pixelWidth = PREVIEW_SIZE / width;
|
||||
final double pixelHeight = PREVIEW_SIZE / height;
|
||||
for (int index = 0; index < indices.size(); index++) {
|
||||
final int paletteIndex = indices.get(index);
|
||||
final int x = index % width;
|
||||
final int y = index / width;
|
||||
graphics.setFill(toFxColor(paletteIndexToArgb(colors, paletteIndex)));
|
||||
graphics.fillRect(x * pixelWidth, y * pixelHeight, pixelWidth, pixelHeight);
|
||||
}
|
||||
}
|
||||
|
||||
private void clearCanvas(GraphicsContext graphics) {
|
||||
final double cell = 12.0d;
|
||||
for (int y = 0; y < PREVIEW_SIZE / cell; y++) {
|
||||
for (int x = 0; x < PREVIEW_SIZE / cell; x++) {
|
||||
final boolean even = ((x + y) % 2) == 0;
|
||||
graphics.setFill(even
|
||||
? javafx.scene.paint.Color.rgb(20, 28, 38)
|
||||
: javafx.scene.paint.Color.rgb(28, 36, 48));
|
||||
graphics.fillRect(x * cell, y * cell, cell, cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> nestedMap(Map<String, Object> metadata, String key) {
|
||||
final Object value = metadata.get(key);
|
||||
if (value instanceof Map<?, ?> nested) {
|
||||
return (Map<String, Object>) nested;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Integer> paletteIndices(Object value) {
|
||||
if (value instanceof List<?> list) {
|
||||
return (List<Integer>) list;
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Integer> paletteDisplayColors(Map<String, Object> metadata) {
|
||||
final Map<String, Object> palette = nestedMap(metadata, "palette");
|
||||
if (palette == null) {
|
||||
return List.of();
|
||||
}
|
||||
final Object converted = palette.get("convertedRgb565");
|
||||
if (converted instanceof List<?> list) {
|
||||
return ((List<Integer>) list).stream()
|
||||
.map(this::rgb565ToArgb8888)
|
||||
.toList();
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private Integer asInteger(Object value) {
|
||||
if (value instanceof Number number) {
|
||||
return number.intValue();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private int paletteIndexToArgb(List<Integer> colors, int paletteIndex) {
|
||||
if (paletteIndex <= 0) {
|
||||
return 0x00000000;
|
||||
}
|
||||
return colorAt(colors, paletteIndex - 1);
|
||||
}
|
||||
|
||||
private int colorAt(List<Integer> colors, int index) {
|
||||
if (index < 0 || index >= colors.size()) {
|
||||
return 0x00000000;
|
||||
}
|
||||
final Integer color = colors.get(index);
|
||||
return color == null ? 0x00000000 : color;
|
||||
}
|
||||
|
||||
private String cssColor(int argb) {
|
||||
final int alpha = (argb >>> 24) & 0xFF;
|
||||
if (alpha == 0) {
|
||||
return "transparent";
|
||||
}
|
||||
final int red = (argb >>> 16) & 0xFF;
|
||||
final int green = (argb >>> 8) & 0xFF;
|
||||
final int blue = argb & 0xFF;
|
||||
return String.format("#%02X%02X%02X", red, green, blue);
|
||||
}
|
||||
|
||||
private javafx.scene.paint.Color toFxColor(int argb) {
|
||||
final int alpha = (argb >>> 24) & 0xFF;
|
||||
if (alpha == 0) {
|
||||
return javafx.scene.paint.Color.TRANSPARENT;
|
||||
}
|
||||
final int red = (argb >>> 16) & 0xFF;
|
||||
final int green = (argb >>> 8) & 0xFF;
|
||||
final int blue = argb & 0xFF;
|
||||
return javafx.scene.paint.Color.rgb(red, green, blue, alpha / 255.0d);
|
||||
}
|
||||
|
||||
private int rgb565ToArgb8888(Integer rgb565) {
|
||||
if (rgb565 == null) {
|
||||
return 0x00000000;
|
||||
}
|
||||
final int value = rgb565 & 0xFFFF;
|
||||
final int red = ((value >> 11) & 0x1F) * 255 / 31;
|
||||
final int green = ((value >> 5) & 0x3F) * 255 / 63;
|
||||
final int blue = (value & 0x1F) * 255 / 31;
|
||||
return 0xFF000000 | (red << 16) | (green << 8) | blue;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
package p.studio.workspaces.assets.details.palette;
|
||||
|
||||
import p.studio.workspaces.assets.messages.AssetWorkspaceBankCompositionFile;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public record AssetDetailsPaletteOverhaulingViewModel(
|
||||
boolean editing,
|
||||
boolean dirty,
|
||||
List<AssetWorkspaceBankCompositionFile> previewTileFiles,
|
||||
List<AssetWorkspaceBankCompositionFile> allFiles,
|
||||
List<AssetWorkspaceBankCompositionFile> availableFiles,
|
||||
List<AssetWorkspaceBankCompositionFile> selectedFiles,
|
||||
AssetWorkspaceBankCompositionFile previewTileFile,
|
||||
AssetWorkspaceBankCompositionFile previewPaletteFile,
|
||||
int renderableFileCount) {
|
||||
|
||||
public AssetDetailsPaletteOverhaulingViewModel {
|
||||
previewTileFiles = List.copyOf(Objects.requireNonNull(previewTileFiles, "previewTileFiles"));
|
||||
allFiles = List.copyOf(Objects.requireNonNull(allFiles, "allFiles"));
|
||||
availableFiles = List.copyOf(Objects.requireNonNull(availableFiles, "availableFiles"));
|
||||
selectedFiles = List.copyOf(Objects.requireNonNull(selectedFiles, "selectedFiles"));
|
||||
}
|
||||
}
|
||||
@ -87,6 +87,7 @@ assets.badge.diagnostics=Diagnostics
|
||||
assets.section.summary=Summary
|
||||
assets.section.runtimeContract=Runtime Contract
|
||||
assets.section.bankComposition=Bank Composition
|
||||
assets.section.paletteOverhauling=Palette Overhauling
|
||||
assets.subsection.codecConfiguration=Codec Configuration
|
||||
assets.subsection.metadata=Metadata
|
||||
assets.section.inputsPreview=Inputs / Preview
|
||||
@ -178,6 +179,11 @@ assets.details.bankComposition.available=Available
|
||||
assets.details.bankComposition.selected=Selected
|
||||
assets.details.bankComposition.readonlyHint=Current bank composition state is shown read-only until editing begins.
|
||||
assets.details.bankComposition.editingHint=Bank composition editing shell is active. Behavior wiring lands in the next slice.
|
||||
assets.paletteOverhauling.available=Candidate Palettes
|
||||
assets.paletteOverhauling.selected=Bank Palettes
|
||||
assets.paletteOverhauling.tileSelector=Preview Tile
|
||||
assets.paletteOverhauling.appliedPalette=Applied Palette
|
||||
assets.paletteOverhauling.previewEmpty=Select at least one palette to inspect it on a tile.
|
||||
assets.details.codecConfiguration.empty=This codec does not expose configuration fields yet.
|
||||
assets.details.metadata.empty=This asset does not expose metadata fields yet.
|
||||
assets.addWizard.title=Add Asset
|
||||
|
||||
@ -706,9 +706,9 @@
|
||||
}
|
||||
|
||||
.assets-details-contract-section {
|
||||
-fx-min-height: 372;
|
||||
-fx-pref-height: 372;
|
||||
-fx-max-height: 372;
|
||||
-fx-min-height: 302;
|
||||
-fx-pref-height: 302;
|
||||
-fx-max-height: 302;
|
||||
}
|
||||
|
||||
.assets-details-bank-composition-section {
|
||||
@ -717,10 +717,20 @@
|
||||
-fx-max-height: 372;
|
||||
}
|
||||
|
||||
.assets-details-palette-overhauling-section {
|
||||
-fx-min-height: 446;
|
||||
-fx-pref-height: 446;
|
||||
-fx-max-height: 446;
|
||||
}
|
||||
|
||||
.assets-details-bank-composition-body {
|
||||
-fx-alignment: top-left;
|
||||
}
|
||||
|
||||
.assets-details-palette-overhauling-body {
|
||||
-fx-alignment: top-left;
|
||||
}
|
||||
|
||||
.assets-details-bank-file-cell {
|
||||
-fx-alignment: center-left;
|
||||
}
|
||||
@ -752,6 +762,64 @@
|
||||
-fx-font-size: 10px;
|
||||
}
|
||||
|
||||
.assets-details-palette-item {
|
||||
-fx-alignment: center-left;
|
||||
}
|
||||
|
||||
.assets-details-palette-hash {
|
||||
-fx-text-fill: #9fc3e7;
|
||||
-fx-font-size: 10px;
|
||||
-fx-font-family: "Monaco", "Menlo", monospace;
|
||||
-fx-min-width: 76;
|
||||
}
|
||||
|
||||
.assets-details-palette-strip,
|
||||
.assets-details-palette-preview-strip {
|
||||
-fx-alignment: center-left;
|
||||
}
|
||||
|
||||
.assets-details-palette-swatch,
|
||||
.assets-details-palette-preview-swatch {
|
||||
-fx-min-width: 12;
|
||||
-fx-pref-width: 12;
|
||||
-fx-max-width: 12;
|
||||
-fx-min-height: 18;
|
||||
-fx-pref-height: 18;
|
||||
-fx-max-height: 18;
|
||||
-fx-background-insets: 0;
|
||||
-fx-border-width: 0;
|
||||
-fx-padding: 0;
|
||||
}
|
||||
|
||||
.assets-details-palette-preview {
|
||||
-fx-background-color: #0f1318;
|
||||
-fx-background-radius: 10;
|
||||
-fx-border-color: #2f3a47;
|
||||
-fx-border-radius: 10;
|
||||
-fx-padding: 10;
|
||||
-fx-spacing: 10;
|
||||
}
|
||||
|
||||
.assets-details-palette-preview-label {
|
||||
-fx-text-fill: #9fc3e7;
|
||||
-fx-font-size: 11px;
|
||||
-fx-font-weight: bold;
|
||||
}
|
||||
|
||||
.assets-details-palette-preview-surface {
|
||||
-fx-background-color: #111820;
|
||||
-fx-background-radius: 8;
|
||||
-fx-border-color: #314154;
|
||||
-fx-border-radius: 8;
|
||||
-fx-padding: 8;
|
||||
-fx-min-width: 208;
|
||||
-fx-pref-width: 208;
|
||||
-fx-max-width: 208;
|
||||
-fx-min-height: 208;
|
||||
-fx-pref-height: 208;
|
||||
-fx-max-height: 208;
|
||||
}
|
||||
|
||||
.assets-details-contract-column {
|
||||
-fx-spacing: 8;
|
||||
-fx-min-width: 0;
|
||||
@ -766,9 +834,9 @@
|
||||
-fx-background-radius: 10;
|
||||
-fx-border-color: #2f3a47;
|
||||
-fx-border-radius: 10;
|
||||
-fx-pref-height: 198;
|
||||
-fx-min-height: 198;
|
||||
-fx-max-height: 198;
|
||||
-fx-pref-height: 160;
|
||||
-fx-min-height: 160;
|
||||
-fx-max-height: 160;
|
||||
}
|
||||
|
||||
.assets-details-contract-metadata-scroll > .viewport {
|
||||
@ -971,6 +1039,11 @@
|
||||
-fx-padding: 2 7 2 7;
|
||||
}
|
||||
|
||||
.assets-details-palette-overhauling-section .studio-dual-list-actions > .button:nth-child(3),
|
||||
.assets-details-palette-overhauling-section .studio-dual-list-actions > .button:nth-child(4) {
|
||||
-fx-opacity: 0.45;
|
||||
}
|
||||
|
||||
.studio-asset-capacity-meter-track {
|
||||
-fx-background-color: #10161d;
|
||||
-fx-background-radius: 0;
|
||||
|
||||
@ -112,7 +112,7 @@ final class AssetDetailsBankCompositionCoordinatorTest {
|
||||
files("tile", fileCount, 1024L),
|
||||
List.of(),
|
||||
0L),
|
||||
Map.of());
|
||||
List.of());
|
||||
}
|
||||
|
||||
private AssetWorkspaceAssetDetails soundDetails(long firstSize, long secondSize) {
|
||||
@ -130,7 +130,7 @@ final class AssetDetailsBankCompositionCoordinatorTest {
|
||||
new AssetWorkspaceBankCompositionFile("b.wav", "b.wav", secondSize, 1L, null, Map.of())),
|
||||
List.of(),
|
||||
0L),
|
||||
Map.of());
|
||||
List.of());
|
||||
}
|
||||
|
||||
private List<AssetWorkspaceBankCompositionFile> files(String prefix, int count, long size) {
|
||||
|
||||
@ -0,0 +1,134 @@
|
||||
package p.studio.workspaces.assets.details.palette;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import p.packer.messages.AssetReference;
|
||||
import p.packer.messages.assets.AssetFamilyCatalog;
|
||||
import p.packer.messages.assets.OutputCodecCatalog;
|
||||
import p.packer.messages.assets.OutputFormatCatalog;
|
||||
import p.studio.workspaces.assets.messages.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
final class AssetDetailsPaletteOverhaulingCoordinatorTest {
|
||||
@Test
|
||||
void defaultsToFirstTilePaletteWhenNoSelectedFilesExist() {
|
||||
final AssetDetailsPaletteOverhaulingCoordinator coordinator = new AssetDetailsPaletteOverhaulingCoordinator();
|
||||
coordinator.replaceDetails(tileDetails(List.of(), files("tile", 3)));
|
||||
|
||||
assertTrue(coordinator.ready());
|
||||
assertEquals(0, coordinator.viewModel().selectedFiles().size());
|
||||
assertNull(coordinator.viewModel().previewTileFile());
|
||||
assertNull(coordinator.viewModel().previewPaletteFile());
|
||||
}
|
||||
|
||||
@Test
|
||||
void clickingSelectedPaletteChangesOnlyPreviewPalette() {
|
||||
final AssetDetailsPaletteOverhaulingCoordinator coordinator = new AssetDetailsPaletteOverhaulingCoordinator();
|
||||
coordinator.replaceDetails(tileDetails(List.of(files("tile", 3).get(0), files("tile", 3).get(1)), List.of(files("tile", 3).get(2))));
|
||||
coordinator.beginEdit();
|
||||
|
||||
final AssetWorkspaceBankCompositionFile first = coordinator.viewModel().availableFiles().get(0);
|
||||
coordinator.moveToSelected(List.of(first));
|
||||
coordinator.selectPreviewPalette(first);
|
||||
|
||||
assertEquals(first.path(), coordinator.viewModel().previewPaletteFile().path());
|
||||
assertEquals(1, coordinator.viewModel().selectedFiles().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void removingPreviewedPaletteFallsBackToFirstRemainingSelectedPalette() {
|
||||
final AssetDetailsPaletteOverhaulingCoordinator coordinator = new AssetDetailsPaletteOverhaulingCoordinator();
|
||||
final List<AssetWorkspaceBankCompositionFile> generated = files("tile", 3);
|
||||
coordinator.replaceDetails(tileDetails(List.of(generated.get(0), generated.get(1)), List.of(generated.get(2))));
|
||||
coordinator.beginEdit();
|
||||
final AssetWorkspaceBankCompositionFile first = coordinator.viewModel().availableFiles().get(0);
|
||||
coordinator.moveToSelected(List.of(first));
|
||||
coordinator.selectPreviewPalette(first);
|
||||
|
||||
coordinator.moveToAvailable(List.of(first));
|
||||
|
||||
assertEquals(1, coordinator.viewModel().selectedFiles().size());
|
||||
assertEquals(first.path(), coordinator.viewModel().previewPaletteFile().path());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deduplicatesPalettesByColorHash() {
|
||||
final AssetDetailsPaletteOverhaulingCoordinator coordinator = new AssetDetailsPaletteOverhaulingCoordinator();
|
||||
final AssetWorkspaceBankCompositionFile first = file("tile-a.png", List.of(0xFFFF0000, 0xFF00FF00));
|
||||
final AssetWorkspaceBankCompositionFile duplicate = file("tile-b.png", List.of(0xFFFF0000, 0xFF00FF00));
|
||||
final AssetWorkspaceBankCompositionFile unique = file("tile-c.png", List.of(0xFF0000FF));
|
||||
|
||||
coordinator.replaceDetails(tileDetails(List.of(first), List.of(duplicate, unique)));
|
||||
|
||||
assertEquals(2, coordinator.viewModel().allFiles().size());
|
||||
assertEquals(10, coordinator.viewModel().allFiles().getFirst().displayName().length());
|
||||
assertEquals(coordinator.viewModel().allFiles().stream().map(AssetWorkspaceBankCompositionFile::displayName).distinct().count(),
|
||||
coordinator.viewModel().allFiles().size());
|
||||
}
|
||||
|
||||
private AssetWorkspaceAssetDetails tileDetails(
|
||||
List<AssetWorkspaceBankCompositionFile> selectedFiles,
|
||||
List<AssetWorkspaceBankCompositionFile> availableFiles) {
|
||||
return new AssetWorkspaceAssetDetails(
|
||||
new AssetWorkspaceAssetSummary(
|
||||
AssetReference.forAssetId(1),
|
||||
"bank",
|
||||
AssetWorkspaceAssetState.REGISTERED,
|
||||
AssetWorkspaceBuildParticipation.INCLUDED,
|
||||
1,
|
||||
AssetFamilyCatalog.TILE_BANK,
|
||||
Path.of("/tmp/bank"),
|
||||
false,
|
||||
false),
|
||||
List.of(),
|
||||
OutputFormatCatalog.TILES_INDEXED_V1,
|
||||
OutputCodecCatalog.NONE,
|
||||
List.of(OutputCodecCatalog.NONE),
|
||||
Map.of(OutputCodecCatalog.NONE, List.of()),
|
||||
List.of(),
|
||||
new AssetWorkspaceBankCompositionDetails(availableFiles, selectedFiles, 0L),
|
||||
List.of());
|
||||
}
|
||||
|
||||
private List<AssetWorkspaceBankCompositionFile> files(String prefix, int count) {
|
||||
return java.util.stream.IntStream.range(0, count)
|
||||
.mapToObj(index -> file(
|
||||
prefix + "-" + index + ".png",
|
||||
List.of(
|
||||
0xFF000000 | ((index + 1) << 16),
|
||||
0xFF000000 | ((index + 1) << 8),
|
||||
0xFF000000 | (index + 1))))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private AssetWorkspaceBankCompositionFile file(String path, List<Integer> colors) {
|
||||
return new AssetWorkspaceBankCompositionFile(
|
||||
path,
|
||||
path,
|
||||
256L,
|
||||
1L,
|
||||
null,
|
||||
Map.of(
|
||||
"tile", Map.of(
|
||||
"width", 2,
|
||||
"height", 2,
|
||||
"paletteIndices", List.of(0, 1, 2, 0)),
|
||||
"palette", Map.of(
|
||||
"originalArgb8888", colors,
|
||||
"convertedRgb565", colors.stream().map(this::toRgb565).toList())));
|
||||
}
|
||||
|
||||
private int toRgb565(int argb8888) {
|
||||
final int red = (argb8888 >> 16) & 0xFF;
|
||||
final int green = (argb8888 >> 8) & 0xFF;
|
||||
final int blue = argb8888 & 0xFF;
|
||||
final int r5 = (red >> 3) & 0x1F;
|
||||
final int g6 = (green >> 2) & 0x3F;
|
||||
final int b5 = (blue >> 3) & 0x1F;
|
||||
return (r5 << 11) | (g6 << 5) | b5;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -10,17 +10,17 @@
|
||||
"asset_id" : 7,
|
||||
"asset_uuid" : "62a81570-8f47-4612-9288-6060e6c9a2e2",
|
||||
"root" : "ui/one-more-atlas",
|
||||
"included_in_build" : true
|
||||
"included_in_build" : false
|
||||
}, {
|
||||
"asset_id" : 8,
|
||||
"asset_uuid" : "9a7386e7-6f0e-4e4c-9919-0de71e0b7031",
|
||||
"root" : "ui/sound",
|
||||
"included_in_build" : true
|
||||
"included_in_build" : false
|
||||
}, {
|
||||
"asset_id" : 11,
|
||||
"asset_uuid" : "64147d33-e8bf-4272-bb5c-b4c07c0276b3",
|
||||
"root" : "bigode",
|
||||
"included_in_build" : true
|
||||
"included_in_build" : false
|
||||
}, {
|
||||
"asset_id" : 12,
|
||||
"asset_uuid" : "b15b319f-5cab-4254-93ea-d83f4742d204",
|
||||
|
||||
@ -12,6 +12,6 @@
|
||||
}
|
||||
},
|
||||
"preload" : {
|
||||
"enabled" : true
|
||||
"enabled" : false
|
||||
}
|
||||
}
|
||||
@ -12,7 +12,10 @@
|
||||
}
|
||||
},
|
||||
"preload" : {
|
||||
"enabled" : false
|
||||
"enabled" : true
|
||||
},
|
||||
"artifacts" : [ ]
|
||||
"artifacts" : [ {
|
||||
"file" : "right-palette.png",
|
||||
"index" : 0
|
||||
} ]
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 172 B After Width: | Height: | Size: 327 B |
Loading…
x
Reference in New Issue
Block a user