added asset workspace working with packer

This commit is contained in:
bQUARKz 2026-03-11 19:01:57 +00:00
parent 1506858b25
commit 1f6df50f09
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
10 changed files with 209 additions and 45 deletions

View File

@ -145,6 +145,7 @@ public enum I18n {
ASSETS_INPUTS_EMPTY("assets.inputs.empty"),
ASSETS_DIAGNOSTICS_EMPTY("assets.diagnostics.empty"),
ASSETS_PREVIEW_EMPTY("assets.preview.empty"),
ASSETS_PREVIEW_ZOOM("assets.preview.zoom"),
ASSETS_PREVIEW_TEXT_ERROR("assets.preview.textError"),
ASSETS_PREVIEW_IMAGE_ERROR("assets.preview.imageError"),
ASSETS_PREVIEW_AUDIO_PLACEHOLDER("assets.preview.audioPlaceholder"),

View File

@ -9,11 +9,11 @@ public final class AssetNavigatorProjectionBuilder {
public static AssetNavigatorProjection build(
List<AssetWorkspaceAssetSummary> assets,
Path assetsRoot,
Path projectRoot,
String searchQuery,
Set<AssetNavigatorFilter> filters) {
Objects.requireNonNull(assets, "assets");
final Path normalizedAssetsRoot = Objects.requireNonNull(assetsRoot, "assetsRoot").toAbsolutePath().normalize();
final Path normalizedProjectRoot = Objects.requireNonNull(projectRoot, "projectRoot").toAbsolutePath().normalize();
final String normalizedQuery = normalizeQuery(searchQuery);
final Set<AssetNavigatorFilter> normalizedFilters = filters == null || filters.isEmpty()
? EnumSet.noneOf(AssetNavigatorFilter.class)
@ -21,10 +21,10 @@ public final class AssetNavigatorProjectionBuilder {
final Map<String, List<AssetWorkspaceAssetSummary>> grouped = new LinkedHashMap<>();
for (AssetWorkspaceAssetSummary asset : assets) {
if (!matchesFilters(asset, normalizedFilters) || !matchesQuery(asset, normalizedAssetsRoot, normalizedQuery)) {
if (!matchesFilters(asset, normalizedFilters) || !matchesQuery(asset, normalizedProjectRoot, normalizedQuery)) {
continue;
}
grouped.computeIfAbsent(groupLabel(asset, normalizedAssetsRoot), ignored -> new ArrayList<>())
grouped.computeIfAbsent(groupLabel(asset, normalizedProjectRoot), ignored -> new ArrayList<>())
.add(asset);
}
@ -35,8 +35,8 @@ public final class AssetNavigatorProjectionBuilder {
return new AssetNavigatorProjection(groups, visibleAssetCount);
}
static String relativeRoot(AssetWorkspaceAssetSummary asset, Path assetsRoot) {
return relativize(asset.assetRoot(), assetsRoot).toString().replace('\\', '/');
static String relativeRoot(AssetWorkspaceAssetSummary asset, Path projectRoot) {
return relativize(asset.assetRoot(), projectRoot).toString().replace('\\', '/');
}
private static boolean matchesFilters(AssetWorkspaceAssetSummary asset, Set<AssetNavigatorFilter> filters) {
@ -65,29 +65,29 @@ public final class AssetNavigatorProjectionBuilder {
return true;
}
private static boolean matchesQuery(AssetWorkspaceAssetSummary asset, Path assetsRoot, String normalizedQuery) {
private static boolean matchesQuery(AssetWorkspaceAssetSummary asset, Path projectRoot, String normalizedQuery) {
if (normalizedQuery.isBlank()) {
return true;
}
final String relativeRoot = relativeRoot(asset, assetsRoot);
final String relativeRoot = relativeRoot(asset, projectRoot);
return asset.assetName().toLowerCase(Locale.ROOT).contains(normalizedQuery)
|| asset.assetFamily().toLowerCase(Locale.ROOT).contains(normalizedQuery)
|| relativeRoot.toLowerCase(Locale.ROOT).contains(normalizedQuery);
}
private static String groupLabel(AssetWorkspaceAssetSummary asset, Path assetsRoot) {
final Path relativeRoot = relativize(asset.assetRoot(), assetsRoot);
private static String groupLabel(AssetWorkspaceAssetSummary asset, Path projectRoot) {
final Path relativeRoot = relativize(asset.assetRoot(), projectRoot);
final Path parent = relativeRoot.getParent();
if (parent == null) {
return "assets";
return relativeRoot.toString().replace('\\', '/');
}
return parent.toString().replace('\\', '/');
}
private static Path relativize(Path assetRoot, Path assetsRoot) {
private static Path relativize(Path assetRoot, Path projectRoot) {
try {
return assetsRoot.relativize(assetRoot.toAbsolutePath().normalize());
return projectRoot.relativize(assetRoot.toAbsolutePath().normalize());
} catch (IllegalArgumentException ignored) {
return assetRoot.getFileName();
}

View File

@ -9,24 +9,20 @@ import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.*;
import p.packer.declarations.PackerAssetDeclarationParser;
import p.packer.foundation.PackerWorkspaceFoundation;
import p.packer.workspace.FileSystemPackerWorkspaceService;
import p.studio.Container;
import p.studio.events.*;
import p.studio.projects.ProjectReference;
import p.studio.utilities.i18n.I18n;
import p.studio.workspaces.Workspace;
import p.studio.workspaces.WorkspaceId;
import p.packer.declarations.PackerAssetDeclarationParser;
import p.packer.foundation.PackerWorkspaceFoundation;
import p.packer.workspace.FileSystemPackerWorkspaceService;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.*;
import java.util.concurrent.CompletableFuture;
public final class AssetWorkspace implements Workspace {
@ -46,6 +42,7 @@ public final class AssetWorkspace implements Workspace {
private final VBox detailsContent = new VBox(12);
private final Label workspaceSummaryLabel = new Label();
private final TextArea logsArea = new TextArea();
private final ScrollPane detailsScroll = new ScrollPane();
private final Map<AssetNavigatorFilter, ToggleButton> filterButtons = new EnumMap<>(AssetNavigatorFilter.class);
private final EnumSet<AssetNavigatorFilter> activeFilters = EnumSet.noneOf(AssetNavigatorFilter.class);
@ -56,6 +53,7 @@ public final class AssetWorkspace implements Workspace {
private volatile String detailsErrorMessage;
private volatile AssetWorkspaceMutationPreview stagedMutationPreview;
private volatile Path selectedPreviewInput;
private volatile int selectedPreviewZoom = 1;
private String searchQuery = "";
public AssetWorkspace(ProjectReference projectReference) {
@ -167,7 +165,7 @@ public final class AssetWorkspace implements Workspace {
detailsTitle.getStyleClass().add("assets-workspace-pane-title");
workspaceSummaryLabel.getStyleClass().add("assets-workspace-summary");
detailsContent.getStyleClass().add("assets-workspace-details-content");
final ScrollPane detailsScroll = new ScrollPane(detailsContent);
detailsScroll.setContent(detailsContent);
detailsScroll.setFitToWidth(true);
detailsScroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
detailsScroll.getStyleClass().add("assets-workspace-details-scroll");
@ -231,6 +229,7 @@ public final class AssetWorkspace implements Workspace {
detailsErrorMessage = null;
stagedMutationPreview = null;
selectedPreviewInput = null;
selectedPreviewZoom = 1;
setInlineProgress(Container.i18n().text(I18n.ASSETS_PROGRESS_REFRESHING), ProgressBar.INDETERMINATE_PROGRESS, true);
appendLog("Assets refresh started.");
renderState();
@ -268,6 +267,7 @@ public final class AssetWorkspace implements Workspace {
detailsErrorMessage = null;
stagedMutationPreview = null;
selectedPreviewInput = null;
selectedPreviewZoom = 1;
setInlineProgress(Container.i18n().text(I18n.ASSETS_PROGRESS_LOADING_DETAILS), ProgressBar.INDETERMINATE_PROGRESS, true);
appendLog("Loading details for " + selectionKey.stableKey() + ".");
renderState();
@ -291,6 +291,7 @@ public final class AssetWorkspace implements Workspace {
selectedAssetDetails = details;
detailsStatus = AssetWorkspaceDetailsStatus.READY;
selectedPreviewInput = firstPreviewInput(details);
selectedPreviewZoom = 1;
setInlineProgress(Container.i18n().text(I18n.ASSETS_PROGRESS_IDLE), 0, false);
appendLog("Asset details ready for " + details.summary().assetName() + ".");
renderState();
@ -319,7 +320,7 @@ public final class AssetWorkspace implements Workspace {
case READY -> {
final AssetNavigatorProjection projection = AssetNavigatorProjectionBuilder.build(
state.assets(),
assetsRoot(),
projectRoot(),
searchQuery,
activeFilters);
if (projection.isEmpty()) {
@ -406,7 +407,7 @@ public final class AssetWorkspace implements Workspace {
createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_STATE), summary.state().name().toLowerCase()),
createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_ASSET_ID), summary.assetId() == null ? "" : String.valueOf(summary.assetId())),
createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_TYPE), summary.assetFamily()),
createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_LOCATION), summary.assetRoot().toString()));
createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_LOCATION), projectRelativePath(summary.assetRoot())));
return createSection(Container.i18n().text(I18n.ASSETS_SECTION_SUMMARY), content);
}
@ -428,6 +429,7 @@ public final class AssetWorkspace implements Workspace {
if (selectedPreviewInput == null || !containsInput(details, selectedPreviewInput)) {
selectedPreviewInput = firstPreviewInput(details);
selectedPreviewZoom = 1;
}
final VBox inputsList = new VBox(8);
@ -445,6 +447,7 @@ public final class AssetWorkspace implements Workspace {
inputButton.setMaxWidth(Double.MAX_VALUE);
inputButton.setOnAction(event -> {
selectedPreviewInput = input;
selectedPreviewZoom = 1;
renderState();
});
roleBox.getChildren().add(inputButton);
@ -565,15 +568,18 @@ public final class AssetWorkspace implements Workspace {
final String extension = extensionOf(input);
if (isImage(extension)) {
try {
final Image image = new Image(input.toUri().toString(), true);
final Image image = new Image(input.toUri().toString(), false);
final ImageView imageView = new ImageView(image);
imageView.setPreserveRatio(true);
imageView.setFitWidth(420);
imageView.setSmooth(false);
imageView.getStyleClass().add("assets-details-preview-image");
previewBox.getChildren().add(createPreviewZoomBar(image));
applyPreviewScale(image, imageView, selectedPreviewZoom);
previewBox.getChildren().add(imageView);
} catch (RuntimeException runtimeException) {
previewBox.getChildren().add(createSectionMessage(Container.i18n().text(I18n.ASSETS_PREVIEW_IMAGE_ERROR)));
}
previewBox.getChildren().add(createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_LOCATION), input.toString()));
previewBox.getChildren().add(createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_LOCATION), projectRelativePath(input)));
return previewBox;
}
@ -588,15 +594,78 @@ public final class AssetWorkspace implements Workspace {
if (isAudio(extension)) {
previewBox.getChildren().add(createSectionMessage(Container.i18n().format(I18n.ASSETS_PREVIEW_AUDIO_PLACEHOLDER, input.getFileName().toString())));
previewBox.getChildren().add(createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_LOCATION), input.toString()));
previewBox.getChildren().add(createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_LOCATION), projectRelativePath(input)));
return previewBox;
}
previewBox.getChildren().add(createSectionMessage(Container.i18n().format(I18n.ASSETS_PREVIEW_GENERIC_PLACEHOLDER, input.getFileName().toString())));
previewBox.getChildren().add(createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_LOCATION), input.toString()));
previewBox.getChildren().add(createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_LOCATION), projectRelativePath(input)));
return previewBox;
}
private Node createPreviewZoomBar(Image image) {
final HBox zoomBar = new HBox(8);
zoomBar.setAlignment(Pos.CENTER_LEFT);
zoomBar.getStyleClass().add("assets-details-preview-zoom-bar");
final Label zoomLabel = new Label(Container.i18n().text(I18n.ASSETS_PREVIEW_ZOOM));
zoomLabel.getStyleClass().add("assets-details-preview-zoom-label");
zoomBar.getChildren().add(zoomLabel);
final ToggleGroup zoomGroup = new ToggleGroup();
final int maxZoom = maxPreviewZoom(image);
for (int zoom : List.of(1, 2, 4, 8)) {
final ToggleButton button = new ToggleButton("x" + zoom);
button.getStyleClass().add("assets-details-preview-zoom-button");
button.setToggleGroup(zoomGroup);
button.setSelected(selectedPreviewZoom == zoom);
button.setDisable(zoom > maxZoom);
button.setOnAction(event -> {
selectedPreviewZoom = zoom;
renderState();
});
zoomBar.getChildren().add(button);
}
return zoomBar;
}
private void applyPreviewScale(Image image, ImageView imageView, int requestedZoom) {
final double width = image.getWidth();
final double height = image.getHeight();
if (width <= 0.0d || height <= 0.0d) {
imageView.setFitWidth(420);
return;
}
final double scale = previewScale(width, height, requestedZoom);
imageView.setFitWidth(Math.max(1.0d, width * scale));
imageView.setFitHeight(Math.max(1.0d, height * scale));
imageView.setSmooth(false);
}
static double previewScale(double width, double height, int requestedZoom) {
final double longestEdge = Math.max(width, height);
if (longestEdge <= 0.0d) {
return 1.0d;
}
if (longestEdge > 420.0d) {
return 420.0d / longestEdge;
}
return Math.max(1, Math.min(Math.max(1, requestedZoom), maxPreviewZoom(width, height)));
}
static int maxPreviewZoom(Image image) {
return maxPreviewZoom(image.getWidth(), image.getHeight());
}
static int maxPreviewZoom(double width, double height) {
final double longestEdge = Math.max(width, height);
if (longestEdge <= 0.0d || longestEdge > 420.0d) {
return 1;
}
return Math.max(1, (int) Math.floor(420.0d / longestEdge));
}
private Node createSection(String title, Node content) {
final VBox section = new VBox(10);
section.getStyleClass().add("assets-details-section");
@ -678,7 +747,7 @@ public final class AssetWorkspace implements Workspace {
}
topLine.getChildren().addAll(icon, name, spacer, badges);
final Label path = new Label(AssetNavigatorProjectionBuilder.relativeRoot(asset, assetsRoot()));
final Label path = new Label(AssetNavigatorProjectionBuilder.relativeRoot(asset, projectRoot()));
path.getStyleClass().add("assets-workspace-asset-path");
row.getChildren().addAll(topLine, path);
row.setOnMouseClicked(event -> selectAsset(asset.selectionKey()));
@ -722,10 +791,18 @@ public final class AssetWorkspace implements Workspace {
if (selectedAsset == null) {
return;
}
final AssetWorkspaceMutationPreview preview = mutationService.preview(projectReference, selectedAsset, action);
stagedMutationPreview = preview;
appendLog("Preview ready for " + actionLabel(action) + ".");
renderState();
try {
stagedMutationPreview = mutationService.preview(projectReference, selectedAsset, action);
appendLog("Preview ready for " + actionLabel(action) + ".");
renderState();
Platform.runLater(() -> detailsScroll.setVvalue(1.0d));
} catch (RuntimeException runtimeException) {
final String message = rootCauseMessage(runtimeException);
appendLog("Preview failed: " + message);
workspaceBus.publish(new StudioAssetsMutationFailedEvent(projectReference, action, message));
stagedMutationPreview = null;
renderState();
}
}
private Node createStagedMutationPanel(AssetWorkspaceMutationPreview preview) {
@ -786,7 +863,7 @@ public final class AssetWorkspace implements Workspace {
box.getChildren().add(createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_NAME), preview.asset().assetName()));
box.getChildren().add(createKeyValueRow(
Container.i18n().text(I18n.ASSETS_LABEL_LOCATION),
AssetNavigatorProjectionBuilder.relativeRoot(preview.asset(), assetsRoot())));
projectRelativePath(preview.asset().assetRoot())));
return box;
}
@ -921,6 +998,19 @@ public final class AssetWorkspace implements Workspace {
return projectReference.rootPath().resolve("assets").toAbsolutePath().normalize();
}
private Path projectRoot() {
return projectReference.rootPath().toAbsolutePath().normalize();
}
private String projectRelativePath(Path path) {
final Path normalizedPath = path.toAbsolutePath().normalize();
try {
return projectRoot().relativize(normalizedPath).toString().replace('\\', '/');
} catch (IllegalArgumentException ignored) {
return normalizedPath.toString().replace('\\', '/');
}
}
private void setInlineProgress(String message, double progress, boolean visible) {
inlineProgressLabel.setText(message);
inlineProgressBar.setVisible(visible);

View File

@ -135,6 +135,7 @@ assets.logs.title=Logs
assets.inputs.empty=No previewable inputs are currently declared for this asset.
assets.diagnostics.empty=No diagnostics are currently attached to this asset.
assets.preview.empty=Select an input to preview it here.
assets.preview.zoom=Zoom
assets.preview.textError=Unable to read this text-like input for preview.
assets.preview.imageError=Unable to decode this image for preview.
assets.preview.audioPlaceholder=Audio preview placeholder: {0}

View File

@ -422,6 +422,29 @@
-fx-font-weight: bold;
}
.assets-details-preview-zoom-bar {
-fx-alignment: center-left;
}
.assets-details-preview-zoom-label {
-fx-text-fill: #9fc3e7;
-fx-font-size: 11px;
-fx-font-weight: bold;
}
.assets-details-preview-zoom-button {
-fx-background-color: #17202a;
-fx-text-fill: #e6eff8;
-fx-background-radius: 999;
-fx-border-radius: 999;
-fx-border-color: #2f4053;
}
.assets-details-preview-zoom-button:selected {
-fx-background-color: #224160;
-fx-border-color: #4f8dc3;
}
.assets-details-preview-text {
-fx-control-inner-background: #10161d;
-fx-text-fill: #e4edf6;

View File

@ -11,27 +11,29 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
final class AssetNavigatorProjectionBuilderTest {
@Test
void groupsAssetsByParentPath() {
final Path assetsRoot = Path.of("/tmp/project/assets");
final Path projectRoot = Path.of("/tmp/project");
final Path assetsRoot = projectRoot.resolve("assets");
final AssetNavigatorProjection projection = AssetNavigatorProjectionBuilder.build(
List.of(
managedAsset(1, "ui_atlas", "image_bank", assetsRoot.resolve("ui/atlas"), true, false),
orphanAsset("menu_sounds", "sound_bank", assetsRoot.resolve("audio/menu"), false, false)),
assetsRoot,
projectRoot,
"",
EnumSet.noneOf(AssetNavigatorFilter.class));
assertEquals(2, projection.visibleAssetCount());
assertEquals(List.of("audio", "ui"), projection.groups().stream().map(AssetNavigatorGroup::label).sorted().toList());
assertEquals(List.of("assets/audio", "assets/ui"), projection.groups().stream().map(AssetNavigatorGroup::label).sorted().toList());
}
@Test
void managedAndOrphanFiltersBehaveAsStateFilterSet() {
final Path assetsRoot = Path.of("/tmp/project/assets");
final Path projectRoot = Path.of("/tmp/project");
final Path assetsRoot = projectRoot.resolve("assets");
final AssetNavigatorProjection projection = AssetNavigatorProjectionBuilder.build(
List.of(
managedAsset(1, "ui_atlas", "image_bank", assetsRoot.resolve("ui/atlas"), true, false),
orphanAsset("menu_sounds", "sound_bank", assetsRoot.resolve("audio/menu"), false, false)),
assetsRoot,
projectRoot,
"",
EnumSet.of(AssetNavigatorFilter.MANAGED));
@ -41,13 +43,14 @@ final class AssetNavigatorProjectionBuilderTest {
@Test
void diagnosticsAndPreloadActAsAdditionalConstraints() {
final Path assetsRoot = Path.of("/tmp/project/assets");
final Path projectRoot = Path.of("/tmp/project");
final Path assetsRoot = projectRoot.resolve("assets");
final AssetNavigatorProjection projection = AssetNavigatorProjectionBuilder.build(
List.of(
managedAsset(1, "ui_atlas", "image_bank", assetsRoot.resolve("ui/atlas"), true, true),
managedAsset(2, "bg_tiles", "image_bank", assetsRoot.resolve("bg/tiles"), true, false),
managedAsset(3, "voice_bank", "sound_bank", assetsRoot.resolve("audio/voice"), false, true)),
assetsRoot,
projectRoot,
"",
EnumSet.of(AssetNavigatorFilter.MANAGED, AssetNavigatorFilter.PRELOAD, AssetNavigatorFilter.DIAGNOSTICS));
@ -57,19 +60,20 @@ final class AssetNavigatorProjectionBuilderTest {
@Test
void searchMatchesAssetNameAndPathContext() {
final Path assetsRoot = Path.of("/tmp/project/assets");
final Path projectRoot = Path.of("/tmp/project");
final Path assetsRoot = projectRoot.resolve("assets");
final List<AssetWorkspaceAssetSummary> assets = List.of(
managedAsset(1, "ui_atlas", "image_bank", assetsRoot.resolve("ui/atlas"), true, false),
orphanAsset("menu_sounds", "sound_bank", assetsRoot.resolve("audio/menu"), false, false));
final AssetNavigatorProjection byName = AssetNavigatorProjectionBuilder.build(
assets,
assetsRoot,
projectRoot,
"atlas",
EnumSet.noneOf(AssetNavigatorFilter.class));
final AssetNavigatorProjection byPath = AssetNavigatorProjectionBuilder.build(
assets,
assetsRoot,
projectRoot,
"audio",
EnumSet.noneOf(AssetNavigatorFilter.class));

View File

@ -0,0 +1,28 @@
package p.studio.workspaces.assets;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
final class AssetWorkspacePreviewScaleTest {
@Test
void keepsSmallImageAtOriginalScaleByDefault() {
assertEquals(1.0d, AssetWorkspace.previewScale(16.0d, 16.0d, 1));
}
@Test
void appliesIntegerZoomForSmallImages() {
assertEquals(8.0d, AssetWorkspace.previewScale(16.0d, 16.0d, 8));
}
@Test
void capsZoomOptionsByMaximumPreviewSize() {
assertEquals(6, AssetWorkspace.maxPreviewZoom(64.0d, 64.0d));
assertEquals(1.0d, AssetWorkspace.previewScale(300.0d, 200.0d, 8));
}
@Test
void scalesLargeImagesDownToPreviewLimit() {
assertEquals(420.0d / 512.0d, AssetWorkspace.previewScale(512.0d, 128.0d, 1));
}
}

View File

@ -0,0 +1,9 @@
{
"schema_version" : 1,
"next_asset_id" : 2,
"assets" : [ {
"asset_id" : 1,
"asset_uuid" : "67cd978d-cd61-4641-ba9e-98fe4bc039bd",
"root" : "ui/atlas-relocated"
} ]
}

View File

@ -0,0 +1,8 @@
{
"schema_version": 1,
"name": "ui_atlas",
"type": "image_bank",
"inputs": { "sprites": ["sprites/confirm.png"] },
"output": { "format": "TILES/indexed_v1", "codec": "RAW" },
"preload": { "enabled": true }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 B