diff --git a/docs/studio/specs/2. Studio UI Foundations Specification.md b/docs/studio/specs/2. Studio UI Foundations Specification.md index 865825f0..3f721fb2 100644 --- a/docs/studio/specs/2. Studio UI Foundations Specification.md +++ b/docs/studio/specs/2. Studio UI Foundations Specification.md @@ -79,6 +79,7 @@ The exact event catalog may evolve incrementally. Illustrative baseline categories include: - project lifecycle events; +- project loading lifecycle events; - workspace selection events; - run and build intent events; - activity publication events; @@ -86,6 +87,20 @@ Illustrative baseline categories include: - diagnostics update events; - asset selection events. +For project bootstrap and splash/loading integration, the Studio event catalog should reserve typed events for: + +- project loading started; +- project loading progress; +- project loading completed; +- project loading failed. + +Project loading progress events should carry at least: + +- the project identity, +- a typed loading phase, +- a user-facing status message, +- and either determinate progress or an explicit indeterminate marker. + ## Theme and i18n Compatibility Shared Studio UI foundations must preserve: diff --git a/prometeu-studio/src/main/java/p/studio/StudioAppInfo.java b/prometeu-studio/src/main/java/p/studio/StudioAppInfo.java new file mode 100644 index 00000000..77183ea5 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/StudioAppInfo.java @@ -0,0 +1,22 @@ +package p.studio; + +import java.util.Optional; + +public final class StudioAppInfo { + private static final String VERSION_PROPERTY = "prometeu.studio.version"; + private static final String FALLBACK_VERSION = "dev"; + + private StudioAppInfo() { + } + + public static String version() { + final String explicit = System.getProperty(VERSION_PROPERTY); + if (explicit != null && !explicit.isBlank()) { + return explicit.trim(); + } + + return Optional.ofNullable(StudioAppInfo.class.getPackage().getImplementationVersion()) + .filter(value -> !value.isBlank()) + .orElse(FALLBACK_VERSION); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/events/StudioProjectLoadingCompletedEvent.java b/prometeu-studio/src/main/java/p/studio/events/StudioProjectLoadingCompletedEvent.java new file mode 100644 index 00000000..40de1681 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/events/StudioProjectLoadingCompletedEvent.java @@ -0,0 +1,11 @@ +package p.studio.events; + +import p.studio.projects.ProjectReference; + +import java.util.Objects; + +public record StudioProjectLoadingCompletedEvent(ProjectReference project) implements StudioEvent { + public StudioProjectLoadingCompletedEvent { + Objects.requireNonNull(project, "project"); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/events/StudioProjectLoadingFailedEvent.java b/prometeu-studio/src/main/java/p/studio/events/StudioProjectLoadingFailedEvent.java new file mode 100644 index 00000000..29aef0ef --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/events/StudioProjectLoadingFailedEvent.java @@ -0,0 +1,17 @@ +package p.studio.events; + +import p.studio.projects.ProjectReference; + +import java.util.Objects; + +public record StudioProjectLoadingFailedEvent( + ProjectReference project, + StudioProjectLoadingPhase phase, + String message) implements StudioEvent { + + public StudioProjectLoadingFailedEvent { + Objects.requireNonNull(project, "project"); + Objects.requireNonNull(phase, "phase"); + Objects.requireNonNull(message, "message"); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/events/StudioProjectLoadingPhase.java b/prometeu-studio/src/main/java/p/studio/events/StudioProjectLoadingPhase.java new file mode 100644 index 00000000..a089d5a9 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/events/StudioProjectLoadingPhase.java @@ -0,0 +1,9 @@ +package p.studio.events; + +public enum StudioProjectLoadingPhase { + RESOLVING_PROJECT, + RESTORING_WINDOW_STATE, + RESTORING_WORKSPACES, + INITIALIZING_SERVICES, + READY +} diff --git a/prometeu-studio/src/main/java/p/studio/events/StudioProjectLoadingProgressEvent.java b/prometeu-studio/src/main/java/p/studio/events/StudioProjectLoadingProgressEvent.java new file mode 100644 index 00000000..471f4ec3 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/events/StudioProjectLoadingProgressEvent.java @@ -0,0 +1,22 @@ +package p.studio.events; + +import p.studio.projects.ProjectReference; + +import java.util.Objects; + +public record StudioProjectLoadingProgressEvent( + ProjectReference project, + StudioProjectLoadingPhase phase, + String message, + double progress, + boolean indeterminate) implements StudioEvent { + + public StudioProjectLoadingProgressEvent { + Objects.requireNonNull(project, "project"); + Objects.requireNonNull(phase, "phase"); + Objects.requireNonNull(message, "message"); + if (!indeterminate && (progress < 0.0d || progress > 1.0d)) { + throw new IllegalArgumentException("progress must be between 0.0 and 1.0 when determinate"); + } + } +} diff --git a/prometeu-studio/src/main/java/p/studio/events/StudioProjectLoadingStartedEvent.java b/prometeu-studio/src/main/java/p/studio/events/StudioProjectLoadingStartedEvent.java new file mode 100644 index 00000000..2efa4bab --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/events/StudioProjectLoadingStartedEvent.java @@ -0,0 +1,11 @@ +package p.studio.events; + +import p.studio.projects.ProjectReference; + +import java.util.Objects; + +public record StudioProjectLoadingStartedEvent(ProjectReference project) implements StudioEvent { + public StudioProjectLoadingStartedEvent { + Objects.requireNonNull(project, "project"); + } +} 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 05721a6c..936af89b 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 @@ -5,6 +5,16 @@ import lombok.Getter; public enum I18n { APP_TITLE("app.title"), APP_PROJECT_TITLE("app.projectTitle"), + SHIELD_LOADING_PROJECT("shield.loadingProject"), + SHIELD_VERSION("shield.version"), + SHIELD_STATUS_INDEXING("shield.status.indexing"), + SHIELD_STATUS_RESTORING("shield.status.restoring"), + SHIELD_PHASE_INDEXING("shield.phase.indexing"), + SHIELD_PHASE_PREPARING("shield.phase.preparing"), + SHIELD_PHASE_RESTORING("shield.phase.restoring"), + SHIELD_TIP_SHORTCUTS("shield.tip.shortcuts"), + SHIELD_TIP_WORKSPACES("shield.tip.workspaces"), + SHIELD_TIP_ACTIVITY("shield.tip.activity"), MENU_FILE("menu.file"), MENU_FILE_NEWPROJECT("menu.file.newProject"), diff --git a/prometeu-studio/src/main/java/p/studio/window/ProjectLoadingShieldView.java b/prometeu-studio/src/main/java/p/studio/window/ProjectLoadingShieldView.java new file mode 100644 index 00000000..cf33c466 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/window/ProjectLoadingShieldView.java @@ -0,0 +1,112 @@ +package p.studio.window; + +import javafx.animation.KeyFrame; +import javafx.animation.Timeline; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Label; +import javafx.scene.control.ProgressBar; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.util.Duration; +import p.studio.Container; +import p.studio.StudioAppInfo; +import p.studio.projects.ProjectReference; +import p.studio.utilities.i18n.I18n; + +import java.util.Objects; + +public final class ProjectLoadingShieldView extends VBox { + public ProjectLoadingShieldView(ProjectReference projectReference) { + Objects.requireNonNull(projectReference, "projectReference"); + + getStyleClass().add("studio-project-loading-shield"); + setPadding(new Insets(28)); + setSpacing(14); + setAlignment(Pos.CENTER_LEFT); + + final Label markLabel = new Label("P"); + markLabel.getStyleClass().add("studio-project-loading-mark"); + + final Label appTitle = new Label(Container.i18n().text(I18n.APP_TITLE)); + appTitle.getStyleClass().add("studio-project-loading-title"); + + final Label appVersion = new Label(Container.i18n().format(I18n.SHIELD_VERSION, StudioAppInfo.version())); + appVersion.getStyleClass().add("studio-project-loading-version"); + + final VBox brandBlock = new VBox(4, appTitle, appVersion); + final HBox brandRow = new HBox(14, buildMark(markLabel), brandBlock); + brandRow.setAlignment(Pos.CENTER_LEFT); + + final Label projectLabel = new Label(projectReference.name()); + projectLabel.getStyleClass().add("studio-project-loading-project"); + + final Label statusLabel = new Label(Container.i18n().format(I18n.SHIELD_LOADING_PROJECT, projectReference.name())); + statusLabel.getStyleClass().add("studio-project-loading-status"); + + final ProgressBar progressBar = new ProgressBar(0.18); + progressBar.getStyleClass().add("studio-project-loading-progress"); + progressBar.setMaxWidth(Double.MAX_VALUE); + + final HBox phaseRow = new HBox( + 8, + phasePill(Container.i18n().text(I18n.SHIELD_PHASE_INDEXING), true), + phasePill(Container.i18n().text(I18n.SHIELD_PHASE_PREPARING), false), + phasePill(Container.i18n().text(I18n.SHIELD_PHASE_RESTORING), false)); + phaseRow.setAlignment(Pos.CENTER_LEFT); + + final Label tipLabel = new Label(Container.i18n().text(I18n.SHIELD_TIP_SHORTCUTS)); + tipLabel.getStyleClass().add("studio-project-loading-tip"); + tipLabel.setWrapText(true); + + final Timeline timeline = new Timeline( + new KeyFrame(Duration.ZERO, ignored -> { + statusLabel.setText(Container.i18n().format(I18n.SHIELD_LOADING_PROJECT, projectReference.name())); + tipLabel.setText(Container.i18n().text(I18n.SHIELD_TIP_SHORTCUTS)); + progressBar.setProgress(0.24); + }), + new KeyFrame(Duration.millis(500), ignored -> { + statusLabel.setText(Container.i18n().text(I18n.SHIELD_STATUS_INDEXING)); + tipLabel.setText(Container.i18n().text(I18n.SHIELD_TIP_WORKSPACES)); + progressBar.setProgress(0.58); + }), + new KeyFrame(Duration.millis(1000), ignored -> { + statusLabel.setText(Container.i18n().text(I18n.SHIELD_STATUS_RESTORING)); + tipLabel.setText(Container.i18n().text(I18n.SHIELD_TIP_ACTIVITY)); + progressBar.setProgress(0.86); + })); + timeline.setCycleCount(Timeline.INDEFINITE); + timeline.play(); + + sceneProperty().addListener((ignored, oldScene, newScene) -> { + if (newScene == null) { + timeline.stop(); + } + }); + + getChildren().addAll( + brandRow, + projectLabel, + statusLabel, + progressBar, + phaseRow, + tipLabel); + } + + private StackPane buildMark(Label markLabel) { + final StackPane mark = new StackPane(markLabel); + mark.getStyleClass().add("studio-project-loading-mark-shell"); + return mark; + } + + private Region phasePill(String text, boolean active) { + final Label label = new Label(text); + label.getStyleClass().add("studio-project-loading-phase"); + if (active) { + label.getStyleClass().add("studio-project-loading-phase-active"); + } + return label; + } +} diff --git a/prometeu-studio/src/main/java/p/studio/window/StudioWindowCoordinator.java b/prometeu-studio/src/main/java/p/studio/window/StudioWindowCoordinator.java index ed5c1c17..493ec5e6 100644 --- a/prometeu-studio/src/main/java/p/studio/window/StudioWindowCoordinator.java +++ b/prometeu-studio/src/main/java/p/studio/window/StudioWindowCoordinator.java @@ -1,7 +1,10 @@ package p.studio.window; +import javafx.animation.PauseTransition; import javafx.scene.Scene; import javafx.stage.Stage; +import javafx.stage.StageStyle; +import javafx.util.Duration; import p.studio.Container; import p.studio.events.StudioProjectCreatedEvent; import p.studio.events.StudioProjectOpenedEvent; @@ -16,8 +19,11 @@ import java.util.Objects; public final class StudioWindowCoordinator { private static final double LAUNCHER_WIDTH = 760; private static final double LAUNCHER_HEIGHT = 750; + private static final double PROJECT_LOADING_WIDTH = 460; + private static final double PROJECT_LOADING_HEIGHT = 220; private static final double PROJECT_WIDTH = 1280; private static final double PROJECT_HEIGHT = 840; + private static final Duration PROJECT_OPEN_DELAY = Duration.seconds(1.5); private final Stage launcherStage; private final ProjectCatalogService projectCatalogService; @@ -83,6 +89,7 @@ public final class StudioWindowCoordinator { } private void openProjectWindow(ProjectReference projectReference) { + final Stage loadingStage = createProjectLoadingStage(projectReference); final Stage projectStage = new Stage(); final Scene scene = new Scene(new MainView(projectReference), PROJECT_WIDTH, PROJECT_HEIGHT); scene.getStylesheets().add(Container.theme().getDefaultTheme()); @@ -98,8 +105,30 @@ public final class StudioWindowCoordinator { }); launcherStage.hide(); - projectStage.show(); - projectStage.toFront(); + loadingStage.show(); + loadingStage.centerOnScreen(); + loadingStage.toFront(); + final PauseTransition delay = new PauseTransition(PROJECT_OPEN_DELAY); + delay.setOnFinished(ignored -> { + loadingStage.close(); + projectStage.show(); + projectStage.toFront(); + }); + delay.play(); + } + + private Stage createProjectLoadingStage(ProjectReference projectReference) { + final Stage loadingStage = new Stage(StageStyle.UNDECORATED); + loadingStage.initOwner(launcherStage); + loadingStage.setResizable(false); + + final Scene scene = new Scene( + new ProjectLoadingShieldView(projectReference), + PROJECT_LOADING_WIDTH, + PROJECT_LOADING_HEIGHT); + scene.getStylesheets().add(Container.theme().getDefaultTheme()); + loadingStage.setScene(scene); + return loadingStage; } private Path resolveDefaultProjectsRoot() { diff --git a/prometeu-studio/src/main/resources/i18n/messages.properties b/prometeu-studio/src/main/resources/i18n/messages.properties index d97c3007..8cf69c56 100644 --- a/prometeu-studio/src/main/resources/i18n/messages.properties +++ b/prometeu-studio/src/main/resources/i18n/messages.properties @@ -1,5 +1,15 @@ app.title=Prometeu Studio app.projectTitle=Prometeu Studio - {0} +shield.loadingProject=Loading project {0}... +shield.version=Version {0} +shield.status.indexing=Indexing sources and restoring services... +shield.status.restoring=Restoring workspace surfaces and editor state... +shield.phase.indexing=Indexing +shield.phase.preparing=Preparing UI +shield.phase.restoring=Restoring State +shield.tip.shortcuts=Tip: the Studio shell will grow around workspace-first shortcuts and actions. +shield.tip.workspaces=Tip: workspaces stay fixed on the left rail for fast switching. +shield.tip.activity=Tip: global activity lives on the right; local logs stay in each workspace. menu.file=File menu.file.newProject=New Project diff --git a/prometeu-studio/src/main/resources/themes/default-prometeu.css b/prometeu-studio/src/main/resources/themes/default-prometeu.css index 6ec429b9..5d63d367 100644 --- a/prometeu-studio/src/main/resources/themes/default-prometeu.css +++ b/prometeu-studio/src/main/resources/themes/default-prometeu.css @@ -138,3 +138,70 @@ .studio-launcher-feedback { -fx-text-fill: #ffb08f; } + +.studio-project-loading-shield { + -fx-background-color: linear-gradient(to bottom right, #102236, #0c1016); +} + +.studio-project-loading-mark-shell { + -fx-background-color: linear-gradient(to bottom right, #4aa3ff, #1f5e97); + -fx-background-radius: 16; + -fx-min-width: 46; + -fx-min-height: 46; + -fx-pref-width: 46; + -fx-pref-height: 46; + -fx-alignment: center; +} + +.studio-project-loading-mark { + -fx-text-fill: #ffffff; + -fx-font-size: 24px; + -fx-font-weight: bold; +} + +.studio-project-loading-title { + -fx-text-fill: #f7fbff; + -fx-font-size: 28px; + -fx-font-weight: bold; +} + +.studio-project-loading-version { + -fx-text-fill: #9fc4ea; + -fx-font-size: 13px; +} + +.studio-project-loading-project { + -fx-text-fill: #ffffff; + -fx-font-size: 20px; + -fx-font-weight: bold; + -fx-padding: 12 0 0 0; +} + +.studio-project-loading-status { + -fx-text-fill: #d9e7f5; + -fx-font-size: 14px; +} + +.studio-project-loading-progress { + -fx-accent: #5cb6ff; + -fx-pref-height: 10px; + -fx-max-width: 380px; +} + +.studio-project-loading-phase { + -fx-background-color: rgba(255,255,255,0.08); + -fx-background-radius: 999; + -fx-text-fill: #d6e3f2; + -fx-font-size: 11px; + -fx-padding: 5 10 5 10; +} + +.studio-project-loading-phase-active { + -fx-background-color: #2c6ba5; + -fx-text-fill: #ffffff; +} + +.studio-project-loading-tip { + -fx-text-fill: #b9cae0; + -fx-font-size: 12px; +} diff --git a/prometeu-studio/src/test/java/p/studio/events/StudioWorkspaceEventBusTest.java b/prometeu-studio/src/test/java/p/studio/events/StudioWorkspaceEventBusTest.java index 600896fc..11115bfe 100644 --- a/prometeu-studio/src/test/java/p/studio/events/StudioWorkspaceEventBusTest.java +++ b/prometeu-studio/src/test/java/p/studio/events/StudioWorkspaceEventBusTest.java @@ -1,8 +1,10 @@ package p.studio.events; import org.junit.jupiter.api.Test; +import p.studio.projects.ProjectReference; import p.studio.workspaces.WorkspaceId; +import java.nio.file.Path; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; @@ -41,6 +43,25 @@ final class StudioWorkspaceEventBusTest { assertEquals(List.of("global-only"), globalReceived); } + @Test + void projectLoadingProgressEventsCanFlowThroughTheStudioBus() { + final StudioEventBus globalBus = new StudioEventBus(); + final StudioWorkspaceEventBus workspaceBus = new StudioWorkspaceEventBus(WorkspaceId.ASSETS, globalBus); + final List globalReceived = new CopyOnWriteArrayList<>(); + final ProjectReference project = new ProjectReference("Main", "1.0.0", "pbs", 1, Path.of("/tmp/main")); + + globalBus.subscribe(StudioProjectLoadingProgressEvent.class, event -> globalReceived.add(event.phase())); + + workspaceBus.publish(new StudioProjectLoadingProgressEvent( + project, + StudioProjectLoadingPhase.INITIALIZING_SERVICES, + "Initializing services", + 0.5d, + false)); + + assertEquals(List.of(StudioProjectLoadingPhase.INITIALIZING_SERVICES), globalReceived); + } + private record TestStudioEvent(String name) implements StudioEvent { } }