From 824a39436a707ea2abb3f7cf86fe279fa8b29d9b Mon Sep 17 00:00:00 2001 From: bQUARKz Date: Wed, 11 Mar 2026 14:09:18 +0000 Subject: [PATCH] update shell --- .../src/main/java/p/studio/App.java | 4 +- .../events/StudioProjectCreatedEvent.java | 6 + .../events/StudioProjectOpenedEvent.java | 6 + .../projects/ProjectCatalogService.java | 80 +++++++++++ .../p/studio/projects/ProjectReference.java | 10 ++ .../java/p/studio/utilities/i18n/I18n.java | 8 ++ .../main/java/p/studio/window/MainView.java | 23 +-- .../p/studio/window/ProjectLauncherView.java | 132 ++++++++++++++++++ .../java/p/studio/window/StudioRootView.java | 53 +++++++ .../workspaces/builder/BuilderWorkspace.java | 9 +- .../main/resources/i18n/messages.properties | 7 + .../resources/themes/default-prometeu.css | 29 ++++ .../projects/ProjectCatalogServiceTest.java | 42 ++++++ 13 files changed, 396 insertions(+), 13 deletions(-) create mode 100644 prometeu-studio/src/main/java/p/studio/events/StudioProjectCreatedEvent.java create mode 100644 prometeu-studio/src/main/java/p/studio/events/StudioProjectOpenedEvent.java create mode 100644 prometeu-studio/src/main/java/p/studio/projects/ProjectCatalogService.java create mode 100644 prometeu-studio/src/main/java/p/studio/projects/ProjectReference.java create mode 100644 prometeu-studio/src/main/java/p/studio/window/ProjectLauncherView.java create mode 100644 prometeu-studio/src/main/java/p/studio/window/StudioRootView.java create mode 100644 prometeu-studio/src/test/java/p/studio/projects/ProjectCatalogServiceTest.java diff --git a/prometeu-studio/src/main/java/p/studio/App.java b/prometeu-studio/src/main/java/p/studio/App.java index d79b215a..16515e64 100644 --- a/prometeu-studio/src/main/java/p/studio/App.java +++ b/prometeu-studio/src/main/java/p/studio/App.java @@ -4,7 +4,7 @@ import javafx.application.Application; import javafx.scene.Scene; import javafx.stage.Stage; import p.studio.utilities.i18n.I18n; -import p.studio.window.MainView; +import p.studio.window.StudioRootView; public class App extends Application { @@ -16,7 +16,7 @@ public class App extends Application { @Override public void start(Stage stage) { - var root = new MainView(); + var root = new StudioRootView(); var scene = new Scene(root, 1200, 800); scene.getStylesheets().add(Container.theme().getDefaultTheme()); diff --git a/prometeu-studio/src/main/java/p/studio/events/StudioProjectCreatedEvent.java b/prometeu-studio/src/main/java/p/studio/events/StudioProjectCreatedEvent.java new file mode 100644 index 00000000..db24ebde --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/events/StudioProjectCreatedEvent.java @@ -0,0 +1,6 @@ +package p.studio.events; + +import p.studio.projects.ProjectReference; + +public record StudioProjectCreatedEvent(ProjectReference project) implements StudioEvent { +} diff --git a/prometeu-studio/src/main/java/p/studio/events/StudioProjectOpenedEvent.java b/prometeu-studio/src/main/java/p/studio/events/StudioProjectOpenedEvent.java new file mode 100644 index 00000000..08c2d35b --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/events/StudioProjectOpenedEvent.java @@ -0,0 +1,6 @@ +package p.studio.events; + +import p.studio.projects.ProjectReference; + +public record StudioProjectOpenedEvent(ProjectReference project) implements StudioEvent { +} diff --git a/prometeu-studio/src/main/java/p/studio/projects/ProjectCatalogService.java b/prometeu-studio/src/main/java/p/studio/projects/ProjectCatalogService.java new file mode 100644 index 00000000..deeacda5 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/projects/ProjectCatalogService.java @@ -0,0 +1,80 @@ +package p.studio.projects; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.stream.Stream; + +public final class ProjectCatalogService { + private final Path projectsRoot; + + public ProjectCatalogService(Path projectsRoot) { + this.projectsRoot = Objects.requireNonNull(projectsRoot, "projectsRoot").toAbsolutePath().normalize(); + } + + public Path projectsRoot() { + return projectsRoot; + } + + public List listProjects() { + if (!Files.isDirectory(projectsRoot)) { + return List.of(); + } + + try (Stream children = Files.list(projectsRoot)) { + return children + .filter(Files::isDirectory) + .map(path -> new ProjectReference(path.getFileName().toString(), path)) + .sorted(Comparator.comparing(ProjectReference::name, String.CASE_INSENSITIVE_ORDER)) + .toList(); + } catch (IOException ioException) { + throw new UncheckedIOException(ioException); + } + } + + public ProjectReference openProject(Path projectPath) { + final Path normalized = Objects.requireNonNull(projectPath, "projectPath").toAbsolutePath().normalize(); + if (!Files.isDirectory(normalized)) { + throw new IllegalArgumentException("project directory does not exist: " + normalized); + } + return new ProjectReference(normalized.getFileName().toString(), normalized); + } + + public ProjectReference createProject(String projectName) { + final String sanitized = sanitizeProjectName(projectName); + if (sanitized.isBlank()) { + throw new IllegalArgumentException("project name must not be blank"); + } + + final Path projectRoot = projectsRoot.resolve(sanitized).normalize(); + if (Files.exists(projectRoot)) { + throw new IllegalArgumentException("project already exists: " + sanitized); + } + + try { + Files.createDirectories(projectRoot.resolve(".workspace")); + Files.createDirectories(projectRoot.resolve("src")); + Files.createDirectories(projectRoot.resolve("build")); + Files.createDirectories(projectRoot.resolve("cartridge")); + } catch (IOException ioException) { + throw new UncheckedIOException(ioException); + } + + return new ProjectReference(sanitized, projectRoot); + } + + private String sanitizeProjectName(String rawName) { + final String normalized = Objects.requireNonNull(rawName, "rawName") + .trim() + .toLowerCase(Locale.ROOT) + .replaceAll("[^a-z0-9]+", "-") + .replaceAll("^-+", "") + .replaceAll("-+$", ""); + return normalized; + } +} diff --git a/prometeu-studio/src/main/java/p/studio/projects/ProjectReference.java b/prometeu-studio/src/main/java/p/studio/projects/ProjectReference.java new file mode 100644 index 00000000..22772c9a --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/projects/ProjectReference.java @@ -0,0 +1,10 @@ +package p.studio.projects; + +import java.nio.file.Path; + +public record ProjectReference(String name, Path rootPath) { + @Override + public String toString() { + return name; + } +} 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 4c076465..f75f7e33 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 @@ -16,6 +16,14 @@ public enum I18n { SHELL_ACTIVITY("shell.activity"), + LAUNCHER_TITLE("launcher.title"), + LAUNCHER_SUBTITLE("launcher.subtitle"), + LAUNCHER_EXISTING_PROJECTS("launcher.existingProjects"), + LAUNCHER_OPEN_PROJECT("launcher.openProject"), + LAUNCHER_CREATE_PROJECT("launcher.createProject"), + LAUNCHER_PROJECT_NAME_PROMPT("launcher.projectNamePrompt"), + LAUNCHER_CREATE_BUTTON("launcher.createButton"), + TOOLBAR_PLAY("toolbar.play"), TOOLBAR_STOP("toolbar.stop"), TOOLBAR_EXPORT("toolbar.export"), diff --git a/prometeu-studio/src/main/java/p/studio/window/MainView.java b/prometeu-studio/src/main/java/p/studio/window/MainView.java index eb8c0b55..ca7aeda4 100644 --- a/prometeu-studio/src/main/java/p/studio/window/MainView.java +++ b/prometeu-studio/src/main/java/p/studio/window/MainView.java @@ -4,6 +4,7 @@ import javafx.scene.layout.BorderPane; import p.studio.Container; import p.studio.controls.shell.*; import p.studio.events.StudioWorkspaceSelectedEvent; +import p.studio.projects.ProjectReference; import p.studio.utilities.i18n.I18n; import p.studio.workspaces.PlaceholderWorkspace; import p.studio.workspaces.WorkspaceHost; @@ -14,17 +15,19 @@ import p.studio.workspaces.editor.EditorWorkspace; import java.util.List; public final class MainView extends BorderPane { - private static final WorkspaceHost HOST = new WorkspaceHost(); + private final WorkspaceHost host = new WorkspaceHost(); + private final ProjectReference projectReference; - public MainView() { + public MainView(ProjectReference projectReference) { + this.projectReference = projectReference; final var menuBar = new StudioShellMenuBarControl(); final var runSurface = new StudioRunSurfaceControl(); setTop(new StudioShellTopBarControl(menuBar, runSurface)); - HOST.register(new EditorWorkspace()); - HOST.register(new PlaceholderWorkspace(WorkspaceId.ASSETS, I18n.WORKSPACE_ASSETS, "Assets")); - HOST.register(new BuilderWorkspace()); - HOST.register(new PlaceholderWorkspace(WorkspaceId.DEBUG, I18n.WORKSPACE_DEBUG, "Debug")); + host.register(new EditorWorkspace()); + host.register(new PlaceholderWorkspace(WorkspaceId.ASSETS, I18n.WORKSPACE_ASSETS, "Assets")); + host.register(new BuilderWorkspace(projectReference)); + host.register(new PlaceholderWorkspace(WorkspaceId.DEBUG, I18n.WORKSPACE_DEBUG, "Debug")); final var workspaceRail = new StudioWorkspaceRailControl<>( List.of( @@ -35,7 +38,7 @@ public final class MainView extends BorderPane { ), this::showWorkspace); setLeft(workspaceRail); - setCenter(HOST); + setCenter(host); setRight(new StudioRightUtilityPanelControl( Container.i18n().bind(I18n.SHELL_ACTIVITY), StudioRightUtilityPanelControl.createPlaceholderContent(Container.i18n().bind(I18n.SHELL_ACTIVITY)))); @@ -45,8 +48,12 @@ public final class MainView extends BorderPane { showWorkspace(WorkspaceId.EDITOR); } + public ProjectReference projectReference() { + return projectReference; + } + private void showWorkspace(WorkspaceId workspaceId) { - HOST.show(workspaceId); + host.show(workspaceId); Container.events().publish(new StudioWorkspaceSelectedEvent(workspaceId)); } } diff --git a/prometeu-studio/src/main/java/p/studio/window/ProjectLauncherView.java b/prometeu-studio/src/main/java/p/studio/window/ProjectLauncherView.java new file mode 100644 index 00000000..a121580b --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/window/ProjectLauncherView.java @@ -0,0 +1,132 @@ +package p.studio.window; + +import javafx.collections.FXCollections; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.ListView; +import javafx.scene.control.TextField; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import p.studio.Container; +import p.studio.projects.ProjectCatalogService; +import p.studio.projects.ProjectReference; +import p.studio.utilities.i18n.I18n; + +import java.util.Objects; +import java.util.function.Consumer; + +public final class ProjectLauncherView extends BorderPane { + private final ProjectCatalogService projectCatalogService; + private final Consumer onOpenProject; + private final Consumer onCreateProject; + private final ListView projectList = new ListView<>(); + private final TextField projectNameField = new TextField(); + private final Label feedbackLabel = new Label(); + + public ProjectLauncherView( + ProjectCatalogService projectCatalogService, + Consumer onOpenProject, + Consumer onCreateProject) { + this.projectCatalogService = Objects.requireNonNull(projectCatalogService, "projectCatalogService"); + this.onOpenProject = Objects.requireNonNull(onOpenProject, "onOpenProject"); + this.onCreateProject = Objects.requireNonNull(onCreateProject, "onCreateProject"); + + getStyleClass().add("studio-project-launcher"); + setPadding(new Insets(24)); + setCenter(buildContent()); + + reloadProjects(); + } + + private VBox buildContent() { + final Label title = new Label(); + title.textProperty().bind(Container.i18n().bind(I18n.LAUNCHER_TITLE)); + title.getStyleClass().add("studio-launcher-title"); + + final Label subtitle = new Label(); + subtitle.textProperty().bind(Container.i18n().bind(I18n.LAUNCHER_SUBTITLE)); + subtitle.getStyleClass().add("studio-launcher-subtitle"); + + final Label listTitle = new Label(); + listTitle.textProperty().bind(Container.i18n().bind(I18n.LAUNCHER_EXISTING_PROJECTS)); + listTitle.getStyleClass().add("studio-launcher-section-title"); + + projectList.getStyleClass().add("studio-project-list"); + VBox.setVgrow(projectList, Priority.ALWAYS); + + final Button openButton = new Button(); + openButton.textProperty().bind(Container.i18n().bind(I18n.LAUNCHER_OPEN_PROJECT)); + openButton.disableProperty().bind(projectList.getSelectionModel().selectedItemProperty().isNull()); + openButton.setOnAction(ignored -> openSelectedProject()); + + final HBox openRow = new HBox(openButton); + openRow.setAlignment(Pos.CENTER_LEFT); + + final Label createTitle = new Label(); + createTitle.textProperty().bind(Container.i18n().bind(I18n.LAUNCHER_CREATE_PROJECT)); + createTitle.getStyleClass().add("studio-launcher-section-title"); + + projectNameField.promptTextProperty().bind(Container.i18n().bind(I18n.LAUNCHER_PROJECT_NAME_PROMPT)); + HBox.setHgrow(projectNameField, Priority.ALWAYS); + + final Button createButton = new Button(); + createButton.textProperty().bind(Container.i18n().bind(I18n.LAUNCHER_CREATE_BUTTON)); + createButton.setOnAction(ignored -> createProject()); + + final HBox createRow = new HBox(12, projectNameField, createButton); + createRow.setAlignment(Pos.CENTER_LEFT); + + feedbackLabel.getStyleClass().add("studio-launcher-feedback"); + feedbackLabel.setWrapText(true); + + final VBox content = new VBox(16, + title, + subtitle, + listTitle, + projectList, + openRow, + createTitle, + createRow, + feedbackLabel); + content.setMaxWidth(720); + return content; + } + + private void reloadProjects() { + projectList.setItems(FXCollections.observableArrayList(projectCatalogService.listProjects())); + if (!projectList.getItems().isEmpty()) { + projectList.getSelectionModel().selectFirst(); + } + } + + private void openSelectedProject() { + final ProjectReference selected = projectList.getSelectionModel().getSelectedItem(); + if (selected == null) { + feedbackLabel.textProperty().unbind(); + feedbackLabel.setText(""); + return; + } + + feedbackLabel.setText(""); + onOpenProject.accept(selected); + } + + private void createProject() { + try { + final ProjectReference created = projectCatalogService.createProject(projectNameField.getText()); + projectNameField.clear(); + reloadProjects(); + projectList.getSelectionModel().select(created); + feedbackLabel.textProperty().unbind(); + feedbackLabel.setText(""); + onCreateProject.accept(created); + } catch (IllegalArgumentException illegalArgumentException) { + feedbackLabel.textProperty().unbind(); + feedbackLabel.setText(illegalArgumentException.getMessage()); + } + } +} diff --git a/prometeu-studio/src/main/java/p/studio/window/StudioRootView.java b/prometeu-studio/src/main/java/p/studio/window/StudioRootView.java new file mode 100644 index 00000000..a8b96909 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/window/StudioRootView.java @@ -0,0 +1,53 @@ +package p.studio.window; + +import javafx.scene.layout.StackPane; +import p.studio.Container; +import p.studio.events.StudioProjectCreatedEvent; +import p.studio.events.StudioProjectOpenedEvent; +import p.studio.projects.ProjectCatalogService; +import p.studio.projects.ProjectReference; + +import java.nio.file.Path; + +public final class StudioRootView extends StackPane { + private final ProjectCatalogService projectCatalogService = new ProjectCatalogService(resolveDefaultProjectsRoot()); + + public StudioRootView() { + showLauncher(); + } + + private void showLauncher() { + getChildren().setAll(new ProjectLauncherView( + projectCatalogService, + this::openProject, + this::createProject)); + } + + private void openProject(ProjectReference projectReference) { + final ProjectReference opened = projectCatalogService.openProject(projectReference.rootPath()); + Container.events().publish(new StudioProjectOpenedEvent(opened)); + showShell(opened); + } + + private void createProject(ProjectReference projectReference) { + Container.events().publish(new StudioProjectCreatedEvent(projectReference)); + Container.events().publish(new StudioProjectOpenedEvent(projectReference)); + showShell(projectReference); + } + + private void showShell(ProjectReference projectReference) { + getChildren().setAll(new MainView(projectReference)); + } + + private Path resolveDefaultProjectsRoot() { + Path cursor = Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize(); + while (cursor != null) { + if (cursor.resolve("settings.gradle.kts").toFile().exists() + && cursor.resolve("test-projects").toFile().exists()) { + return cursor.resolve("test-projects"); + } + cursor = cursor.getParent(); + } + return Path.of("test-projects").toAbsolutePath().normalize(); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/builder/BuilderWorkspace.java b/prometeu-studio/src/main/java/p/studio/workspaces/builder/BuilderWorkspace.java index 4fb123b0..bd5eac91 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/builder/BuilderWorkspace.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/builder/BuilderWorkspace.java @@ -8,6 +8,7 @@ import javafx.scene.layout.StackPane; import p.studio.Container; import p.studio.compiler.messages.BuilderPipelineConfig; import p.studio.compiler.workspaces.BuilderPipelineService; +import p.studio.projects.ProjectReference; import p.studio.utilities.i18n.I18n; import p.studio.utilities.logs.LogAggregator; import p.studio.workspaces.Workspace; @@ -18,6 +19,7 @@ public class BuilderWorkspace implements Workspace { private final TextArea logs = new TextArea(); private final Button buildButton = new Button(); private final Button clearButton = new Button(); + private final ProjectReference projectReference; @Override public WorkspaceId id() { @@ -34,7 +36,8 @@ public class BuilderWorkspace implements Workspace { return root; } - public BuilderWorkspace() { + public BuilderWorkspace(ProjectReference projectReference) { + this.projectReference = projectReference; final var toolbar = buildToolBar(); root.setTop(toolbar); @@ -53,7 +56,7 @@ public class BuilderWorkspace implements Workspace { buildButton.setOnAction(e -> { logs.clear(); final var logAggregator = LogAggregator.with(logs::appendText); - final var config = new BuilderPipelineConfig(false, "../test-projects/main"); + final var config = new BuilderPipelineConfig(false, projectReference.rootPath().toString()); BuilderPipelineService.INSTANCE.run(config, logAggregator); }); @@ -64,7 +67,7 @@ public class BuilderWorkspace implements Workspace { } private StackPane buildProjectArea() { - final var tmpLabel = new Label("Builder project area (WIP)"); + final var tmpLabel = new Label("Project: " + projectReference.name()); return new StackPane(tmpLabel); } diff --git a/prometeu-studio/src/main/resources/i18n/messages.properties b/prometeu-studio/src/main/resources/i18n/messages.properties index b887c764..d035d771 100644 --- a/prometeu-studio/src/main/resources/i18n/messages.properties +++ b/prometeu-studio/src/main/resources/i18n/messages.properties @@ -9,6 +9,13 @@ menu.view=View menu.help=Help shell.activity=Activity +launcher.title=Projects +launcher.subtitle=Open an existing project or create a new one to enter the Studio shell. +launcher.existingProjects=Existing Projects +launcher.openProject=Open Project +launcher.createProject=Create New Project +launcher.projectNamePrompt=Project name +launcher.createButton=Create toolbar.play=Play toolbar.stop=Stop diff --git a/prometeu-studio/src/main/resources/themes/default-prometeu.css b/prometeu-studio/src/main/resources/themes/default-prometeu.css index cd8c1431..29c7f483 100644 --- a/prometeu-studio/src/main/resources/themes/default-prometeu.css +++ b/prometeu-studio/src/main/resources/themes/default-prometeu.css @@ -71,3 +71,32 @@ -fx-text-fill: #d4d4d4; -fx-padding: 16; } + +.studio-project-launcher { + -fx-background-color: linear-gradient(to bottom, #191919, #101010); +} + +.studio-launcher-title { + -fx-font-size: 28px; + -fx-font-weight: bold; + -fx-text-fill: #f2f2f2; +} + +.studio-launcher-subtitle { + -fx-font-size: 14px; + -fx-text-fill: #a6a6a6; +} + +.studio-launcher-section-title { + -fx-font-size: 15px; + -fx-font-weight: bold; + -fx-text-fill: #dcdcdc; +} + +.studio-project-list { + -fx-pref-height: 320px; +} + +.studio-launcher-feedback { + -fx-text-fill: #ff9b7d; +} diff --git a/prometeu-studio/src/test/java/p/studio/projects/ProjectCatalogServiceTest.java b/prometeu-studio/src/test/java/p/studio/projects/ProjectCatalogServiceTest.java new file mode 100644 index 00000000..a6e9c063 --- /dev/null +++ b/prometeu-studio/src/test/java/p/studio/projects/ProjectCatalogServiceTest.java @@ -0,0 +1,42 @@ +package p.studio.projects; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class ProjectCatalogServiceTest { + @TempDir + Path tempDir; + + @Test + void listsExistingProjectDirectories() throws Exception { + Files.createDirectories(tempDir.resolve("alpha")); + Files.createDirectories(tempDir.resolve("beta")); + Files.createDirectories(tempDir.resolve("alpha").resolve("src")); + + final ProjectCatalogService service = new ProjectCatalogService(tempDir); + + assertEquals( + java.util.List.of("alpha", "beta"), + service.listProjects().stream().map(ProjectReference::name).toList()); + } + + @Test + void createsProjectWithExpectedStructure() { + final ProjectCatalogService service = new ProjectCatalogService(tempDir); + + final ProjectReference project = service.createProject("My New Project"); + + assertEquals("my-new-project", project.name()); + assertTrue(Files.isDirectory(project.rootPath())); + assertTrue(Files.isDirectory(project.rootPath().resolve(".workspace"))); + assertTrue(Files.isDirectory(project.rootPath().resolve("src"))); + assertTrue(Files.isDirectory(project.rootPath().resolve("build"))); + assertTrue(Files.isDirectory(project.rootPath().resolve("cartridge"))); + } +}