From 17064aa505eda0352813797cd5fcd809a0051582 Mon Sep 17 00:00:00 2001 From: bQUARKz Date: Wed, 11 Mar 2026 16:43:51 +0000 Subject: [PATCH] implements PR-05b asset navigator search filters and selection --- .../java/p/studio/utilities/i18n/I18n.java | 10 + .../assets/AssetNavigatorFilter.java | 8 + .../assets/AssetNavigatorGroup.java | 14 ++ .../assets/AssetNavigatorProjection.java | 17 ++ .../AssetNavigatorProjectionBuilder.java | 99 ++++++++++ .../workspaces/assets/AssetWorkspace.java | 178 +++++++++++++++++- .../main/resources/i18n/messages.properties | 12 +- .../resources/themes/default-prometeu.css | 129 +++++++++++++ .../AssetNavigatorProjectionBuilderTest.java | 116 ++++++++++++ 9 files changed, 575 insertions(+), 8 deletions(-) create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorFilter.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorGroup.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorProjection.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorProjectionBuilder.java create mode 100644 prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetNavigatorProjectionBuilderTest.java diff --git a/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java b/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java index 65dddfa3..380f151f 100644 --- a/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java +++ b/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java @@ -80,10 +80,20 @@ public enum I18n { WORKSPACE_ASSETS("workspace.assets"), ASSETS_NAVIGATOR_TITLE("assets.navigator.title"), ASSETS_DETAILS_TITLE("assets.details.title"), + ASSETS_SEARCH_PROMPT("assets.search.prompt"), + ASSETS_FILTER_MANAGED("assets.filter.managed"), + ASSETS_FILTER_ORPHAN("assets.filter.orphan"), + ASSETS_FILTER_DIAGNOSTICS("assets.filter.diagnostics"), + ASSETS_FILTER_PRELOAD("assets.filter.preload"), ASSETS_STATE_LOADING("assets.state.loading"), ASSETS_STATE_EMPTY("assets.state.empty"), + ASSETS_STATE_NO_RESULTS("assets.state.noResults"), ASSETS_STATE_READY("assets.state.ready"), ASSETS_STATE_ERROR("assets.state.error"), + ASSETS_BADGE_MANAGED("assets.badge.managed"), + ASSETS_BADGE_ORPHAN("assets.badge.orphan"), + ASSETS_BADGE_PRELOAD("assets.badge.preload"), + ASSETS_BADGE_DIAGNOSTICS("assets.badge.diagnostics"), ASSETS_SUMMARY_LOADING("assets.summary.loading"), ASSETS_SUMMARY_EMPTY("assets.summary.empty"), ASSETS_SUMMARY_READY("assets.summary.ready"), diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorFilter.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorFilter.java new file mode 100644 index 00000000..4fd207a8 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorFilter.java @@ -0,0 +1,8 @@ +package p.studio.workspaces.assets; + +public enum AssetNavigatorFilter { + MANAGED, + ORPHAN, + DIAGNOSTICS, + PRELOAD +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorGroup.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorGroup.java new file mode 100644 index 00000000..4ee24553 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorGroup.java @@ -0,0 +1,14 @@ +package p.studio.workspaces.assets; + +import java.util.List; +import java.util.Objects; + +public record AssetNavigatorGroup(String label, List assets) { + public AssetNavigatorGroup { + label = Objects.requireNonNull(label, "label").trim(); + assets = List.copyOf(Objects.requireNonNull(assets, "assets")); + if (label.isBlank()) { + throw new IllegalArgumentException("group label must not be blank"); + } + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorProjection.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorProjection.java new file mode 100644 index 00000000..ccdcc279 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorProjection.java @@ -0,0 +1,17 @@ +package p.studio.workspaces.assets; + +import java.util.List; +import java.util.Objects; + +public record AssetNavigatorProjection(List groups, int visibleAssetCount) { + public AssetNavigatorProjection { + groups = List.copyOf(Objects.requireNonNull(groups, "groups")); + if (visibleAssetCount < 0) { + throw new IllegalArgumentException("visibleAssetCount must not be negative"); + } + } + + public boolean isEmpty() { + return visibleAssetCount == 0; + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorProjectionBuilder.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorProjectionBuilder.java new file mode 100644 index 00000000..1ab32323 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorProjectionBuilder.java @@ -0,0 +1,99 @@ +package p.studio.workspaces.assets; + +import java.nio.file.Path; +import java.util.*; + +public final class AssetNavigatorProjectionBuilder { + private AssetNavigatorProjectionBuilder() { + } + + public static AssetNavigatorProjection build( + List assets, + Path assetsRoot, + String searchQuery, + Set filters) { + Objects.requireNonNull(assets, "assets"); + final Path normalizedAssetsRoot = Objects.requireNonNull(assetsRoot, "assetsRoot").toAbsolutePath().normalize(); + final String normalizedQuery = normalizeQuery(searchQuery); + final Set normalizedFilters = filters == null || filters.isEmpty() + ? EnumSet.noneOf(AssetNavigatorFilter.class) + : EnumSet.copyOf(filters); + + final Map> grouped = new LinkedHashMap<>(); + for (AssetWorkspaceAssetSummary asset : assets) { + if (!matchesFilters(asset, normalizedFilters) || !matchesQuery(asset, normalizedAssetsRoot, normalizedQuery)) { + continue; + } + grouped.computeIfAbsent(groupLabel(asset, normalizedAssetsRoot), ignored -> new ArrayList<>()) + .add(asset); + } + + final List groups = grouped.entrySet().stream() + .map(entry -> new AssetNavigatorGroup(entry.getKey(), entry.getValue())) + .toList(); + final int visibleAssetCount = groups.stream().mapToInt(group -> group.assets().size()).sum(); + return new AssetNavigatorProjection(groups, visibleAssetCount); + } + + static String relativeRoot(AssetWorkspaceAssetSummary asset, Path assetsRoot) { + return relativize(asset.assetRoot(), assetsRoot).toString().replace('\\', '/'); + } + + private static boolean matchesFilters(AssetWorkspaceAssetSummary asset, Set filters) { + if (filters.isEmpty()) { + return true; + } + + final boolean includeManaged = filters.contains(AssetNavigatorFilter.MANAGED); + final boolean includeOrphan = filters.contains(AssetNavigatorFilter.ORPHAN); + if (includeManaged || includeOrphan) { + final boolean stateMatches = (includeManaged && asset.state() == AssetWorkspaceAssetState.MANAGED) + || (includeOrphan && asset.state() == AssetWorkspaceAssetState.ORPHAN); + if (!stateMatches) { + return false; + } + } + + if (filters.contains(AssetNavigatorFilter.DIAGNOSTICS) && !asset.hasDiagnostics()) { + return false; + } + + if (filters.contains(AssetNavigatorFilter.PRELOAD) && !asset.preload()) { + return false; + } + + return true; + } + + private static boolean matchesQuery(AssetWorkspaceAssetSummary asset, Path assetsRoot, String normalizedQuery) { + if (normalizedQuery.isBlank()) { + return true; + } + + final String relativeRoot = relativeRoot(asset, assetsRoot); + 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); + final Path parent = relativeRoot.getParent(); + if (parent == null) { + return "assets"; + } + return parent.toString().replace('\\', '/'); + } + + private static Path relativize(Path assetRoot, Path assetsRoot) { + try { + return assetsRoot.relativize(assetRoot.toAbsolutePath().normalize()); + } catch (IllegalArgumentException ignored) { + return assetRoot.getFileName(); + } + } + + private static String normalizeQuery(String query) { + return query == null ? "" : query.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspace.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspace.java index 12dac284..d1d0cea7 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspace.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspace.java @@ -1,12 +1,10 @@ package p.studio.workspaces.assets; import javafx.application.Platform; +import javafx.geometry.Insets; import javafx.scene.Node; -import javafx.scene.control.Label; -import javafx.scene.control.SplitPane; -import javafx.scene.layout.BorderPane; -import javafx.scene.layout.Priority; -import javafx.scene.layout.VBox; +import javafx.scene.control.*; +import javafx.scene.layout.*; import p.studio.Container; import p.studio.events.*; import p.studio.projects.ProjectReference; @@ -14,17 +12,27 @@ import p.studio.utilities.i18n.I18n; import p.studio.workspaces.Workspace; import p.studio.workspaces.WorkspaceId; +import java.nio.file.Path; import java.util.concurrent.CompletableFuture; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.Map; public final class AssetWorkspace implements Workspace { private final BorderPane root = new BorderPane(); private final ProjectReference projectReference; private final AssetWorkspaceService assetWorkspaceService; private final StudioWorkspaceEventBus workspaceBus; + private final TextField searchField = new TextField(); + private final FlowPane filterBar = new FlowPane(); private final Label navigatorStateLabel = new Label(); + private final VBox navigatorContent = new VBox(8); private final Label detailStateLabel = new Label(); private final Label workspaceSummaryLabel = new Label(); + private final Map filterButtons = new EnumMap<>(AssetNavigatorFilter.class); + private final EnumSet activeFilters = EnumSet.noneOf(AssetNavigatorFilter.class); + private String searchQuery = ""; private volatile AssetWorkspaceState state = AssetWorkspaceState.loading(null); public AssetWorkspace(ProjectReference projectReference) { @@ -70,9 +78,27 @@ public final class AssetWorkspace implements Workspace { final Label navigatorTitle = new Label(); navigatorTitle.textProperty().bind(Container.i18n().bind(I18n.ASSETS_NAVIGATOR_TITLE)); navigatorTitle.getStyleClass().add("assets-workspace-pane-title"); + searchField.setPromptText(Container.i18n().text(I18n.ASSETS_SEARCH_PROMPT)); + searchField.getStyleClass().add("assets-workspace-search"); + searchField.textProperty().addListener((ignored, oldValue, newValue) -> { + final String previous = oldValue == null ? "" : oldValue; + final String current = newValue == null ? "" : newValue; + if (previous.equals(current)) { + return; + } + searchQuery = current; + renderState(); + }); + configureFilterBar(); navigatorStateLabel.getStyleClass().add("assets-workspace-pane-body"); + navigatorContent.getStyleClass().add("assets-workspace-navigator-content"); + final ScrollPane navigatorScroll = new ScrollPane(navigatorContent); + navigatorScroll.setFitToWidth(true); + navigatorScroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + navigatorScroll.getStyleClass().add("assets-workspace-navigator-scroll"); navigatorPane.getStyleClass().add("assets-workspace-pane"); - navigatorPane.getChildren().addAll(navigatorTitle, navigatorStateLabel); + navigatorPane.getChildren().addAll(navigatorTitle, searchField, filterBar, navigatorStateLabel, navigatorScroll); + VBox.setVgrow(navigatorScroll, Priority.ALWAYS); final VBox detailsPane = new VBox(8); final Label detailsTitle = new Label(); @@ -90,6 +116,33 @@ public final class AssetWorkspace implements Workspace { return splitPane; } + private void configureFilterBar() { + filterBar.setHgap(6); + filterBar.setVgap(6); + filterBar.setPadding(new Insets(4, 0, 4, 0)); + filterBar.getStyleClass().add("assets-workspace-filter-bar"); + addFilterButton(AssetNavigatorFilter.MANAGED, I18n.ASSETS_FILTER_MANAGED); + addFilterButton(AssetNavigatorFilter.ORPHAN, I18n.ASSETS_FILTER_ORPHAN); + addFilterButton(AssetNavigatorFilter.DIAGNOSTICS, I18n.ASSETS_FILTER_DIAGNOSTICS); + addFilterButton(AssetNavigatorFilter.PRELOAD, I18n.ASSETS_FILTER_PRELOAD); + } + + private void addFilterButton(AssetNavigatorFilter filter, I18n i18n) { + final ToggleButton button = new ToggleButton(); + button.textProperty().bind(Container.i18n().bind(i18n)); + button.getStyleClass().add("assets-workspace-filter-button"); + button.selectedProperty().addListener((ignored, oldValue, selected) -> { + if (selected) { + activeFilters.add(filter); + } else { + activeFilters.remove(filter); + } + renderState(); + }); + filterButtons.put(filter, button); + filterBar.getChildren().add(button); + } + private void refresh() { state = AssetWorkspaceState.loading(state); renderState(); @@ -117,22 +170,36 @@ public final class AssetWorkspace implements Workspace { switch (state.status()) { case LOADING -> { navigatorStateLabel.setText(Container.i18n().text(I18n.ASSETS_STATE_LOADING)); + navigatorContent.getChildren().setAll(createNavigatorMessage(Container.i18n().text(I18n.ASSETS_STATE_LOADING))); workspaceSummaryLabel.setText(Container.i18n().text(I18n.ASSETS_SUMMARY_LOADING)); detailStateLabel.setText(Container.i18n().text(I18n.ASSETS_DETAILS_LOADING)); } case EMPTY -> { navigatorStateLabel.setText(Container.i18n().text(I18n.ASSETS_STATE_EMPTY)); + navigatorContent.getChildren().setAll(createNavigatorMessage(Container.i18n().text(I18n.ASSETS_STATE_EMPTY))); workspaceSummaryLabel.setText(Container.i18n().text(I18n.ASSETS_SUMMARY_EMPTY)); detailStateLabel.setText(Container.i18n().text(I18n.ASSETS_DETAILS_EMPTY)); } case ERROR -> { navigatorStateLabel.setText(Container.i18n().text(I18n.ASSETS_STATE_ERROR) + "\n\n" + state.errorMessage()); + navigatorContent.getChildren().setAll(createNavigatorMessage(state.errorMessage())); workspaceSummaryLabel.setText(Container.i18n().text(I18n.ASSETS_SUMMARY_ERROR)); detailStateLabel.setText(state.errorMessage()); } case READY -> { final int assetCount = state.assets().size(); - navigatorStateLabel.setText(Container.i18n().format(I18n.ASSETS_STATE_READY, assetCount)); + final AssetNavigatorProjection projection = AssetNavigatorProjectionBuilder.build( + state.assets(), + assetsRoot(), + searchQuery, + activeFilters); + if (projection.isEmpty()) { + navigatorStateLabel.setText(Container.i18n().text(I18n.ASSETS_STATE_NO_RESULTS)); + navigatorContent.getChildren().setAll(createNavigatorMessage(Container.i18n().text(I18n.ASSETS_STATE_NO_RESULTS))); + } else { + navigatorStateLabel.setText(Container.i18n().format(I18n.ASSETS_STATE_READY, projection.visibleAssetCount(), assetCount)); + renderNavigatorProjection(projection); + } workspaceSummaryLabel.setText(Container.i18n().format(I18n.ASSETS_SUMMARY_READY, assetCount)); final String selectedDescription = state.selectedAsset() .map(asset -> Container.i18n().format( @@ -146,6 +213,103 @@ public final class AssetWorkspace implements Workspace { } } + private void renderNavigatorProjection(AssetNavigatorProjection projection) { + navigatorContent.getChildren().clear(); + for (AssetNavigatorGroup group : projection.groups()) { + final VBox groupBox = new VBox(6); + groupBox.getStyleClass().add("assets-workspace-group"); + + final Label groupLabel = new Label(group.label()); + groupLabel.getStyleClass().add("assets-workspace-group-label"); + groupBox.getChildren().add(groupLabel); + + for (AssetWorkspaceAssetSummary asset : group.assets()) { + groupBox.getChildren().add(createAssetRow(asset)); + } + + navigatorContent.getChildren().add(groupBox); + } + } + + private Node createAssetRow(AssetWorkspaceAssetSummary asset) { + final VBox row = new VBox(4); + row.getStyleClass().add("assets-workspace-asset-row"); + if (asset.selectionKey().equals(state.selectedKey())) { + row.getStyleClass().add("assets-workspace-asset-row-selected"); + } + + final HBox topLine = new HBox(8); + topLine.setAlignment(javafx.geometry.Pos.CENTER_LEFT); + final Label icon = new Label(assetIcon(asset)); + icon.getStyleClass().add("assets-workspace-asset-icon"); + final Label name = new Label(asset.assetName()); + name.getStyleClass().add("assets-workspace-asset-name"); + final Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + final FlowPane badges = new FlowPane(); + badges.setHgap(6); + badges.setVgap(4); + badges.getChildren().add(createBadge( + asset.state() == AssetWorkspaceAssetState.MANAGED + ? Container.i18n().text(I18n.ASSETS_BADGE_MANAGED) + : Container.i18n().text(I18n.ASSETS_BADGE_ORPHAN), + asset.state() == AssetWorkspaceAssetState.MANAGED + ? "assets-workspace-badge-managed" + : "assets-workspace-badge-orphan")); + badges.getChildren().add(createBadge(asset.assetFamily(), "assets-workspace-badge-family")); + if (asset.preload()) { + badges.getChildren().add(createBadge(Container.i18n().text(I18n.ASSETS_BADGE_PRELOAD), "assets-workspace-badge-preload")); + } + if (asset.hasDiagnostics()) { + badges.getChildren().add(createBadge(Container.i18n().text(I18n.ASSETS_BADGE_DIAGNOSTICS), "assets-workspace-badge-diagnostics")); + } + topLine.getChildren().addAll(icon, name, spacer, badges); + + final Label path = new Label(AssetNavigatorProjectionBuilder.relativeRoot(asset, assetsRoot())); + path.getStyleClass().add("assets-workspace-asset-path"); + row.getChildren().addAll(topLine, path); + row.setOnMouseClicked(event -> selectAsset(asset.selectionKey())); + return row; + } + + private Node createBadge(String text, String styleClass) { + final Label badge = new Label(text); + badge.getStyleClass().add("assets-workspace-badge"); + badge.getStyleClass().add(styleClass); + return badge; + } + + private Node createNavigatorMessage(String text) { + final Label label = new Label(text); + label.getStyleClass().add("assets-workspace-empty-state"); + label.setWrapText(true); + return label; + } + + private void selectAsset(AssetWorkspaceSelectionKey selectionKey) { + state = state.withSelection(selectionKey); + renderState(); + workspaceBus.publish(new StudioAssetsWorkspaceSelectionChangedEvent(projectReference, selectionKey)); + } + + private String assetIcon(AssetWorkspaceAssetSummary asset) { + final String family = asset.assetFamily().toLowerCase(); + if (family.contains("image")) { + return "🖼"; + } + if (family.contains("sound") || family.contains("audio")) { + return "🔊"; + } + if (family.contains("palette")) { + return "🎨"; + } + return "◈"; + } + + private Path assetsRoot() { + return projectReference.rootPath().resolve("assets").toAbsolutePath().normalize(); + } + private String rootCauseMessage(Throwable throwable) { Throwable current = throwable; while (current.getCause() != null) { diff --git a/prometeu-studio/src/main/resources/i18n/messages.properties b/prometeu-studio/src/main/resources/i18n/messages.properties index 451cf58e..341e327b 100644 --- a/prometeu-studio/src/main/resources/i18n/messages.properties +++ b/prometeu-studio/src/main/resources/i18n/messages.properties @@ -70,10 +70,20 @@ workspace.shipper.button.clear=Clear workspace.assets=Assets assets.navigator.title=Asset Navigator assets.details.title=Selected Asset +assets.search.prompt=Search assets by name or path +assets.filter.managed=Managed +assets.filter.orphan=Orphan +assets.filter.diagnostics=Diagnostics +assets.filter.preload=Preload assets.state.loading=Loading assets... assets.state.empty=No managed or orphan assets were found in this project. -assets.state.ready={0} assets loaded. +assets.state.noResults=No assets match the current search or filters. +assets.state.ready={0} visible assets ({1} total). assets.state.error=Asset workspace failed to load. +assets.badge.managed=Managed +assets.badge.orphan=Orphan +assets.badge.preload=Preload +assets.badge.diagnostics=Diagnostics assets.summary.loading=Hydrating asset workspace state... assets.summary.empty=No assets are currently available. assets.summary.ready=Navigator ready with {0} assets. diff --git a/prometeu-studio/src/main/resources/themes/default-prometeu.css b/prometeu-studio/src/main/resources/themes/default-prometeu.css index c3a0b9f7..d2dcb1da 100644 --- a/prometeu-studio/src/main/resources/themes/default-prometeu.css +++ b/prometeu-studio/src/main/resources/themes/default-prometeu.css @@ -133,6 +133,34 @@ -fx-font-weight: bold; } +.assets-workspace-search { + -fx-background-color: #101419; + -fx-text-fill: #f2f7fb; + -fx-prompt-text-fill: #7f8da0; + -fx-background-radius: 10; + -fx-border-color: #283240; + -fx-border-radius: 10; +} + +.assets-workspace-filter-bar { + -fx-padding: 4 0 2 0; +} + +.assets-workspace-filter-button { + -fx-background-color: #12161d; + -fx-text-fill: #c7d5e5; + -fx-background-radius: 999; + -fx-border-radius: 999; + -fx-border-color: #2d3948; + -fx-cursor: hand; +} + +.assets-workspace-filter-button:selected { + -fx-background-color: #24415e; + -fx-border-color: #4d88bc; + -fx-text-fill: #ffffff; +} + .assets-workspace-summary { -fx-text-fill: #9ecbff; -fx-font-size: 12px; @@ -143,6 +171,107 @@ -fx-font-size: 12px; } +.assets-workspace-navigator-scroll { + -fx-background-color: transparent; + -fx-fit-to-width: true; +} + +.assets-workspace-navigator-scroll > .viewport { + -fx-background-color: transparent; +} + +.assets-workspace-navigator-content { + -fx-padding: 4 0 4 0; +} + +.assets-workspace-group { + -fx-spacing: 6; +} + +.assets-workspace-group-label { + -fx-text-fill: #82a9d1; + -fx-font-size: 11px; + -fx-font-weight: bold; +} + +.assets-workspace-asset-row { + -fx-background-color: #11151b; + -fx-background-radius: 10; + -fx-border-radius: 10; + -fx-border-color: #242d38; + -fx-padding: 10; + -fx-cursor: hand; +} + +.assets-workspace-asset-row:hover { + -fx-background-color: #17202a; +} + +.assets-workspace-asset-row-selected { + -fx-background-color: #1d2c3c; + -fx-border-color: #4f8dc3; +} + +.assets-workspace-asset-icon { + -fx-font-size: 14px; +} + +.assets-workspace-asset-name { + -fx-text-fill: #f6fbff; + -fx-font-size: 13px; + -fx-font-weight: bold; +} + +.assets-workspace-asset-path { + -fx-text-fill: #9eacbb; + -fx-font-size: 11px; +} + +.assets-workspace-badge { + -fx-font-size: 10px; + -fx-padding: 3 7 3 7; + -fx-background-radius: 999; + -fx-border-radius: 999; + -fx-border-width: 1; +} + +.assets-workspace-badge-managed { + -fx-background-color: #153425; + -fx-border-color: #2f8f59; + -fx-text-fill: #8ce3ae; +} + +.assets-workspace-badge-orphan { + -fx-background-color: #3a2d14; + -fx-border-color: #bc8a31; + -fx-text-fill: #ffd27a; +} + +.assets-workspace-badge-family { + -fx-background-color: #1c2733; + -fx-border-color: #38506a; + -fx-text-fill: #b7d8f8; +} + +.assets-workspace-badge-preload { + -fx-background-color: #271747; + -fx-border-color: #7f65cf; + -fx-text-fill: #d9cbff; +} + +.assets-workspace-badge-diagnostics { + -fx-background-color: #4a1a1c; + -fx-border-color: #cf6268; + -fx-text-fill: #ffb4b8; +} + +.assets-workspace-empty-state { + -fx-text-fill: #c4ced8; + -fx-font-size: 12px; + -fx-wrap-text: true; + -fx-padding: 10 0 0 0; +} + .studio-project-launcher { -fx-background-color: linear-gradient(to bottom, #20242c, #14181d); } diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetNavigatorProjectionBuilderTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetNavigatorProjectionBuilderTest.java new file mode 100644 index 00000000..5856a850 --- /dev/null +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetNavigatorProjectionBuilderTest.java @@ -0,0 +1,116 @@ +package p.studio.workspaces.assets; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.util.EnumSet; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +final class AssetNavigatorProjectionBuilderTest { + @Test + void groupsAssetsByParentPath() { + final Path assetsRoot = Path.of("/tmp/project/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, + "", + EnumSet.noneOf(AssetNavigatorFilter.class)); + + assertEquals(2, projection.visibleAssetCount()); + assertEquals(List.of("audio", "ui"), projection.groups().stream().map(AssetNavigatorGroup::label).sorted().toList()); + } + + @Test + void managedAndOrphanFiltersBehaveAsStateFilterSet() { + final Path assetsRoot = Path.of("/tmp/project/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, + "", + EnumSet.of(AssetNavigatorFilter.MANAGED)); + + assertEquals(1, projection.visibleAssetCount()); + assertEquals("ui_atlas", projection.groups().getFirst().assets().getFirst().assetName()); + } + + @Test + void diagnosticsAndPreloadActAsAdditionalConstraints() { + final Path assetsRoot = Path.of("/tmp/project/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, + "", + EnumSet.of(AssetNavigatorFilter.MANAGED, AssetNavigatorFilter.PRELOAD, AssetNavigatorFilter.DIAGNOSTICS)); + + assertEquals(1, projection.visibleAssetCount()); + assertEquals("ui_atlas", projection.groups().getFirst().assets().getFirst().assetName()); + } + + @Test + void searchMatchesAssetNameAndPathContext() { + final Path assetsRoot = Path.of("/tmp/project/assets"); + final List 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, + "atlas", + EnumSet.noneOf(AssetNavigatorFilter.class)); + final AssetNavigatorProjection byPath = AssetNavigatorProjectionBuilder.build( + assets, + assetsRoot, + "audio", + EnumSet.noneOf(AssetNavigatorFilter.class)); + + assertEquals(1, byName.visibleAssetCount()); + assertEquals("ui_atlas", byName.groups().getFirst().assets().getFirst().assetName()); + assertEquals(1, byPath.visibleAssetCount()); + assertEquals("menu_sounds", byPath.groups().getFirst().assets().getFirst().assetName()); + } + + private AssetWorkspaceAssetSummary managedAsset( + int assetId, + String name, + String family, + Path root, + boolean preload, + boolean hasDiagnostics) { + return new AssetWorkspaceAssetSummary( + new AssetWorkspaceSelectionKey.ManagedAsset(assetId), + name, + AssetWorkspaceAssetState.MANAGED, + assetId, + family, + root, + preload, + hasDiagnostics); + } + + private AssetWorkspaceAssetSummary orphanAsset( + String name, + String family, + Path root, + boolean preload, + boolean hasDiagnostics) { + return new AssetWorkspaceAssetSummary( + new AssetWorkspaceSelectionKey.OrphanAsset(root), + name, + AssetWorkspaceAssetState.ORPHAN, + null, + family, + root, + preload, + hasDiagnostics); + } +}