From 7dad74bc84912a762930cc2dbccd9a951bf826cd Mon Sep 17 00:00:00 2001 From: Alex Bates Date: Sun, 8 Feb 2026 13:34:59 +0000 Subject: [PATCH] add CreateProjectDialog and project Manifests --- build.gradle.kts | 1 + dev/kdl/KdlDocument.java | 118 +++++++ gradle/verification-metadata.xml | 21 ++ src/main/java/app/Directories.java | 1 + src/main/java/app/Environment.java | 188 ++++------ src/main/java/app/config/Options.java | 3 +- .../java/project/JsonProjectRepository.java | 20 +- src/main/java/project/Manifest.java | 39 +++ src/main/java/project/Project.java | 82 +++-- src/main/java/project/ProjectManager.java | 52 +-- src/main/java/project/ProjectRepository.java | 8 +- src/main/java/project/ProjectValidator.java | 54 --- .../java/project/ui/CreateProjectDialog.java | 327 ++++++++++++++++++ .../java/project/ui/ProjectCellRenderer.java | 2 +- .../project/ui/ProjectSwitcherDialog.java | 58 +--- .../database/templates/blank/.gitignore | 20 ++ .../database/templates/blank/project.kdl | 6 + 17 files changed, 711 insertions(+), 289 deletions(-) create mode 100644 dev/kdl/KdlDocument.java create mode 100644 src/main/java/project/Manifest.java delete mode 100644 src/main/java/project/ProjectValidator.java create mode 100644 src/main/java/project/ui/CreateProjectDialog.java create mode 100644 src/main/resources/database/templates/blank/.gitignore create mode 100644 src/main/resources/database/templates/blank/project.kdl diff --git a/build.gradle.kts b/build.gradle.kts index 5edda59..5403c4e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -88,6 +88,7 @@ dependencies { implementation("com.google.code.gson:gson:2.10.1") implementation("org.yaml:snakeyaml:2.2") + implementation("com.github.kdl-org:kdl4j:v1.0.1") implementation("com.formdev:flatlaf:3.4.1") implementation("com.formdev:flatlaf-intellij-themes:3.4.1") diff --git a/dev/kdl/KdlDocument.java b/dev/kdl/KdlDocument.java new file mode 100644 index 0000000..ce18954 --- /dev/null +++ b/dev/kdl/KdlDocument.java @@ -0,0 +1,118 @@ +package dev.kdl; + +import jakarta.annotation.Nonnull; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * A KDL document. + * + * @param nodes the nodes in the document + */ +public record KdlDocument(@Nonnull List nodes) { + /** + * Creates a new document with the provided nodes. + * + * @param nodes the nodes in the document + */ + public KdlDocument(@Nonnull List nodes) { + this.nodes = Collections.unmodifiableList(nodes); + } + + /** + * Creates a new builder to create a new document from the current one. + * + * @return a new builder with the nodes of this document + */ + @Nonnull + public Builder mutate() { + return new Builder(nodes); + } + + /** + * @return a new document builder + */ + @Nonnull + public static Builder builder() { + return new Builder(); + } + + /** + * A {@link KdlDocument} builder. + */ + public static final class Builder { + + private Builder() { + this.nodes = new ArrayList<>(); + } + + private Builder(List nodes) { + this.nodes = new ArrayList<>(nodes); + } + + /** + * Adds a node to the document being built. + * + * @param node a node to add + * @return this builder + */ + @Nonnull + public Builder node(@Nonnull KdlNode node) { + nodes.add(node); + return this; + } + + /** + * Adds a node to the document being built using a node builder. + * + * @param node a node builder to add + * @return this builder + */ + @Nonnull + public Builder node(@Nonnull KdlNode.Builder node) { + nodes.add(node.build()); + return this; + } + + /** + * Adds nodes to the document being built. + * + * @param nodes nodes to add + * @return this builder + */ + @Nonnull + public Builder nodes(@Nonnull KdlNode... nodes) { + Collections.addAll(this.nodes, nodes); + return this; + } + + /** + * Adds nodes to the document being built using node builders. + * + * @param nodes node builders to add + * @return this builder + */ + @Nonnull + public Builder nodes(@Nonnull KdlNode.Builder... nodes) { + for (var node : nodes) { + this.nodes.add(node.build()); + } + return this; + } + + /** + * Creates a new document. + * + * @return a new KDL document + */ + @Nonnull + public KdlDocument build() { + return new KdlDocument(nodes); + } + + @Nonnull + private final List nodes; + } +} diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 05e5d74..7ce6d41 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -107,6 +107,14 @@ + + + + + + + + @@ -334,6 +342,14 @@ + + + + + + + + @@ -635,6 +651,11 @@ + + + + + diff --git a/src/main/java/app/Directories.java b/src/main/java/app/Directories.java index 87ea244..656aeb7 100644 --- a/src/main/java/app/Directories.java +++ b/src/main/java/app/Directories.java @@ -16,6 +16,7 @@ public enum Directories DATABASE (Root.CONFIG, "/database/"), DATABASE_EDITOR (Root.CONFIG, DATABASE, "/editor/"), DATABASE_THEMES (Root.CONFIG, DATABASE, "/themes/"), + DATABASE_TEMPLATES (Root.CONFIG, DATABASE, "/templates/"), LOGS (Root.STATE, "/logs/"), TEMP (Root.STATE, "/temp/"), diff --git a/src/main/java/app/Environment.java b/src/main/java/app/Environment.java index 09554c9..583948a 100644 --- a/src/main/java/app/Environment.java +++ b/src/main/java/app/Environment.java @@ -21,7 +21,6 @@ import java.util.Properties; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.jar.Attributes; -import java.util.jar.Manifest; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -45,10 +44,11 @@ import app.config.Options.Scope; import app.input.IOUtils; import project.Project; import project.ProjectManager; -import project.ProjectValidator; +import project.Manifest; import project.ui.ProjectSwitcherDialog; import assets.AssetExtractor; import assets.ExpectedAsset; +import dev.kdl.parse.KdlParseException; import game.ProjectDatabase; import game.entity.EntityExtractor; import game.map.editor.ui.dialogs.ChooseDialogResult; @@ -92,8 +92,7 @@ public abstract class Environment public static Config mainConfig = null; public static Config projectConfig = null; - private static DirChooser projectChooser; - private static File projectDirectory = null; + private static Project project = null; private static String gameVersion = ""; @@ -192,7 +191,7 @@ public abstract class Environment if (fromJar) { ClassLoader cl = Environment.class.getClassLoader(); try { - Manifest manifest = new Manifest(cl.getResourceAsStream("META-INF/MANIFEST.MF")); + var manifest = new java.util.jar.Manifest(cl.getResourceAsStream("META-INF/MANIFEST.MF")); Attributes attr = manifest.getMainAttributes(); versionString = attr.getValue("App-Version"); @@ -227,8 +226,6 @@ public abstract class Environment } } - projectChooser = new DirChooser(codeSource.getParentFile(), "Select Project Directory"); - // Create user directories getUserConfigDir().mkdirs(); getUserStateDir().mkdirs(); @@ -260,14 +257,18 @@ public abstract class Environment checkForUpdate(); } - File projDir = chooseProjectDir(); - if (projDir == null) - exit(); - - LoadingBar.show("Loading Project", true); - boolean validProject = loadProject(projDir); - if (!validProject) - exit(); + try { + Project project = chooseProject(); + if (project == null) + exit(); + LoadingBar.show("Loading " + project.getName(), true); + boolean validProject = loadProject(project); + if (!validProject) + exit(1); + } catch (IOException | KdlParseException e) { + showErrorMessage("Failed To Load Project", e.getMessage()); + exit(1); + } } catch (Throwable t) { StarRodMain.handleEarlyCrash(t); @@ -328,19 +329,27 @@ public abstract class Environment return new File("."); } + public static Project getProject() + { + return project; + } + + @Deprecated public static File getProjectDirectory() { - return projectDirectory; + return project.getDirectory(); } + @Deprecated public static File getSourceDirectory() { - return new File(projectDirectory, "/src/"); + return new File(project.getDirectory(), "/src/"); } + @Deprecated public static File getProjectFile(String relativePath) { - return new File(projectDirectory, relativePath); + return new File(project.getDirectory(), relativePath); } public static void checkForUpdate() @@ -424,6 +433,23 @@ public abstract class Environment return new File(dotConfig, "/star-rod/"); } + public static final File getUserDocumentsDir() + { + String userHome = System.getProperty("user.home"); + + if (isWindows()) + return new File(System.getenv("USERPROFILE"), "Documents"); + + if (isMacOS()) + return new File(userHome, "Documents"); + + // Linux: XDG_DOCUMENTS_DIR, fallback to ~/Documents + String xdgDocsDir = System.getenv("XDG_DOCUMENTS_DIR"); + if (xdgDocsDir != null && !xdgDocsDir.isEmpty()) + return new File(xdgDocsDir); + return new File(userHome, "Documents"); + } + public static final File getUserStateDir() { String userHome = System.getProperty("user.home"); @@ -466,111 +492,45 @@ public abstract class Environment } } - private static File chooseProjectDir() throws IOException + private static Project chooseProject() throws IOException, KdlParseException { - // if current directory seems to be a decomp project, use it - if (ProjectValidator.isCurrentDirectoryProject()) { - return new File("."); + // Search current directory and its parents for a project manifest + File currentDir = new File("."); + while (currentDir != null) { + File projectDir = new File(currentDir, Manifest.FILENAME); + if (projectDir.isFile()) { + return new Project(projectDir); + } + currentDir = currentDir.getParentFile(); } - // show project switcher to select a project + // Show project switcher to select a project if (commandLine) { Logger.logError("CWD is not a valid project. Please run Star Rod from a project."); return null; } - Project selected = ProjectSwitcherDialog.showPrompt(); - if (selected != null) { - return selected.getPath(); - } - return null; - } - - public static void promptChangeProject() throws IOException - { - if (projectChooser.prompt() == ChooseDialogResult.APPROVE) { - File dirChoice = projectChooser.getSelectedFile(); - loadProject(dirChoice); - } - } - - private static File promptSelectProject() - { - if (projectChooser.prompt() == ChooseDialogResult.APPROVE) - return projectChooser.getSelectedFile(); - else - return null; - } - - private static void showErrorMessage(String title, String fmt, Object ... args) - { - String message = String.format(fmt, args); - if (isCommandLine()) - Logger.logError(message); - else - SwingUtils.getErrorDialog() - .setTitle(title) - .setMessage(message) - .show(); + return ProjectSwitcherDialog.showPrompt(); } - public static boolean loadProject(File projectDir) throws IOException + public static boolean loadProject(Project newProject) throws IOException { - if (projectDir == null) { - showErrorMessage("Invalid Decomp Project", "No project directory is set."); - return false; - } - - if (!projectDir.exists() || !projectDir.isDirectory()) { - showErrorMessage("Invalid Decomp Project", "Not a valid directory: %n%s", projectDir.getAbsolutePath()); - return false; - } - - // check version to get appropriate splat - gameVersion = mainConfig.getString(Options.GameVersion); - File versionDir = new File(projectDir, "ver/" + gameVersion); - if (!versionDir.exists()) { - showErrorMessage("Invalid Decomp Project", - "Project does not have game version: %s", gameVersion); - return false; - } - - // get splat config - File decompCfg = new File(versionDir, FN_SPLAT); - if (!decompCfg.exists()) { - showErrorMessage("Invalid Decomp Project", - "Could not find splat file for directory: %n%s", decompCfg.getAbsolutePath()); - return false; - } - - // resolve asset dirs - try { - assetDirectories = getAssetDirs(projectDir, decompCfg); - } - catch (IOException e) { - Logger.printStackTrace(e); - showErrorMessage("Splat Read Exception", - "IOException while attempting to read splat file: %n%s %n%s", decompCfg.getAbsolutePath(), - e.getMessage()); - return false; - } + project = newProject; + // TODO: get similar to classic + /* // get US baserom - usBaseRom = new File(projectDir, FN_BASEROM); + usBaseRom = new File(project.getDirectory(), FN_BASEROM); if (!usBaseRom.exists()) { showErrorMessage("Missing US Base ROM", "Could not find US baserom for project. %n" + "Star Rod requries one for asset extraction."); return false; } + */ // save project dir - projectDirectory = projectDir; - SwingUtilities.invokeLater(() -> { - projectChooser.setCurrentDirectory(projectDir); - }); - Directories.setProjectDirectory(projectDirectory.getAbsolutePath()); + Directories.setProjectDirectory(project.getPath()); - readProjectConfig(); reloadIcons(); ProjectDatabase.initialize(); @@ -591,24 +551,22 @@ public abstract class Environment AssetExtractor.extractAll(); // Record that this project was opened - ProjectManager.getInstance().recordProjectOpened(projectDirectory); + ProjectManager.getInstance().recordProjectOpened(project); return true; } - private static void readProjectConfig() throws IOException + public static void showErrorMessage(String title, String fmt, Object ... args) { - File configFile = new File(projectDirectory, FN_PROJ_CONFIG); - - if (!configFile.exists()) { - projectConfig = makeConfig(configFile, Scope.Project); - projectConfig.saveConfigFile(); - } - else { - // config exists, read it - projectConfig = new Config(configFile, Scope.Project); - projectConfig.readConfig(); - } + String message = String.format(fmt, args); + if (isCommandLine()) + Logger.logError(message); + else + SwingUtils.getErrorDialog() + .setTitle(title) + .setMessage(message) + .setOptions("OK") + .show(); } private static Config makeConfig(File configFile, Scope scope) throws IOException diff --git a/src/main/java/app/config/Options.java b/src/main/java/app/config/Options.java index fda5189..3d970e5 100644 --- a/src/main/java/app/config/Options.java +++ b/src/main/java/app/config/Options.java @@ -20,9 +20,10 @@ public enum Options GameVersion (true, Scope.Main, Type.String, "GameVersion", "us"), LogDetails (true, Scope.Main, Type.Boolean, "LogDetails", "false"), - Theme (true, Scope.Main, Type.String, "Theme", "FlatDark"), + Theme (false, Scope.Main, Type.String, "Theme", ""), ExitToMenu (true, Scope.Main, Type.Boolean, "ExitToMenu", "true"), CheckForUpdates (true, Scope.Main, Type.Boolean, "CheckForUpdates", "true"), + ProjectsDir (false, Scope.Main, Type.String, "ProjectsDir", ""), ExtractedMapData (true, Scope.Project, Type.Boolean, "ExtractedMapData", "false"), diff --git a/src/main/java/project/JsonProjectRepository.java b/src/main/java/project/JsonProjectRepository.java index 974a523..74e0c2d 100644 --- a/src/main/java/project/JsonProjectRepository.java +++ b/src/main/java/project/JsonProjectRepository.java @@ -15,6 +15,7 @@ import com.google.gson.GsonBuilder; import com.google.gson.reflect.TypeToken; import app.Environment; +import dev.kdl.parse.KdlParseException; import util.Logger; /** @@ -48,16 +49,15 @@ public class JsonProjectRepository implements ProjectRepository while (iter.hasNext()) { ProjectData data = iter.next(); - File path = new File(data.path); - // Remove invalid entries - if (!path.exists()) { + try { + projects.add(new Project(new File(data.path), data.lastOpened)); + } catch (IOException | KdlParseException e) { + Logger.logWarning("Ignoring invalid project: " + data.path); iter.remove(); modified = true; continue; } - - projects.add(new Project(path, data.lastOpened)); } // Save if we removed any invalid entries @@ -76,7 +76,7 @@ public class JsonProjectRepository implements ProjectRepository List dataList = loadProjectData(); // Remove existing entry with same path (will be re-added with new timestamp) - String absolutePath = project.getPath().getAbsolutePath(); + String absolutePath = project.getPath(); dataList.removeIf(data -> data.path.equals(absolutePath)); // Add new entry @@ -89,19 +89,19 @@ public class JsonProjectRepository implements ProjectRepository } @Override - public synchronized void removeProject(File projectPath) + public synchronized void removeProject(Project project) { List dataList = loadProjectData(); - String absolutePath = projectPath.getAbsolutePath(); + String absolutePath = project.getPath(); dataList.removeIf(data -> data.path.equals(absolutePath)); saveProjectData(dataList); } @Override - public synchronized void updateLastOpened(File projectPath) + public synchronized void updateLastOpened(Project project) { List dataList = loadProjectData(); - String absolutePath = projectPath.getAbsolutePath(); + String absolutePath = project.getPath(); for (ProjectData data : dataList) { if (data.path.equals(absolutePath)) { diff --git a/src/main/java/project/Manifest.java b/src/main/java/project/Manifest.java new file mode 100644 index 0000000..67d801b --- /dev/null +++ b/src/main/java/project/Manifest.java @@ -0,0 +1,39 @@ +package project; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; + +import dev.kdl.KdlDocument; +import dev.kdl.parse.KdlParseException; +import dev.kdl.parse.KdlParser; + +/** A project.kdl file. Mutations are automatically saved to disk. */ +public class Manifest { + public static final String FILENAME = "project.kdl"; + + private final File file; + private final KdlDocument doc; + + public Manifest(Project project) throws IOException, KdlParseException { + file = new File(project.getPath(), FILENAME); + + if (!file.exists()) { + throw new IOException(FILENAME + " does not exist"); + } + + var parser = KdlParser.v2(); + doc = parser.parse(Path.of(file.getAbsolutePath())); + } + public String toString() { + return "Manifest(" + file.getPath() + ")"; + } + + public String getName() { + return doc.nodes().stream() + .filter(n -> n.name().equals("name")) + .findFirst() + .map(n -> n.arguments().get(0).value().toString()) + .orElse(file.getParentFile().getName()); + } +} diff --git a/src/main/java/project/Project.java b/src/main/java/project/Project.java index 2e689ca..9448bf1 100644 --- a/src/main/java/project/Project.java +++ b/src/main/java/project/Project.java @@ -1,37 +1,44 @@ package project; +import static app.Directories.DATABASE_TEMPLATES; + import java.io.File; -import java.util.Objects; +import java.io.IOException; + +import org.apache.commons.io.FileUtils; + +import dev.kdl.parse.KdlParseException; -/** - * Immutable data class representing a Star Rod project. - * Stores the project path and last opened timestamp. - */ public class Project implements Comparable { - private final File path; - private final long lastOpened; + private final File directory; + private final long lastOpened; // TODO: move this, Comparable, and compareTo to a new class + private final Manifest manifest; - public Project(File path, long lastOpened) + /** Loads a project from a directory. */ + public Project(File path, long lastOpened) throws IOException, KdlParseException { - Objects.requireNonNull(path, "Project path cannot be null"); - this.path = path.getAbsoluteFile(); + if (!path.isDirectory()) + throw new IllegalArgumentException("Project path must be a directory: " + path); + this.directory = path.getAbsoluteFile(); this.lastOpened = lastOpened; + this.manifest = new Manifest(this); } - public Project(File path) + /** Loads a project from a directory. */ + public Project(File path) throws IOException, KdlParseException { this(path, System.currentTimeMillis()); } - public File getPath() + public String getPath() { - return path; + return directory.getPath(); } - public String getName() + public File getDirectory() { - return path.getName(); + return directory; } public long getLastOpened() @@ -39,12 +46,41 @@ public class Project implements Comparable return lastOpened; } - /** - * Creates a new Project instance with updated lastOpened timestamp. - */ - public Project withLastOpened(long timestamp) + public String getName() + { + return manifest.getName(); + } + + public Manifest getManifest() + { + return manifest; + } + + /** Creates a new project from a template. */ + public static Project create(File path, String template, String id, String name) throws IOException, KdlParseException { - return new Project(path, timestamp); + if (!path.exists()) + path.mkdirs(); + if (!path.isDirectory()) + throw new IllegalArgumentException("Project path must be a directory: " + path); + + // Copy entire template directory here + File templateDir = DATABASE_TEMPLATES.file(template); + if (!templateDir.exists()) + throw new IllegalArgumentException("Missing template: " + templateDir.getPath()); + FileUtils.copyDirectory(templateDir, path); + + // Substitute placeholders in project.kdl + File manifestFile = new File(path, Manifest.FILENAME); + if (manifestFile.exists()) { + String content = FileUtils.readFileToString(manifestFile, "UTF-8"); + content = content.replace("$PROJECT_ID", id); + content = content.replace("$PROJECT_NAME", name); + content = content.replace("$PROJECT_DESCRIPTION", ""); + FileUtils.writeStringToFile(manifestFile, content, "UTF-8"); + } + + return new Project(path); } @Override @@ -62,18 +98,18 @@ public class Project implements Comparable if (obj == null || getClass() != obj.getClass()) return false; Project other = (Project) obj; - return path.equals(other.path); + return directory.equals(other.directory); } @Override public int hashCode() { - return path.hashCode(); + return directory.hashCode(); } @Override public String toString() { - return getName() + " (" + path.getAbsolutePath() + ")"; + return getName() + " (" + directory.getAbsolutePath() + ")"; } } diff --git a/src/main/java/project/ProjectManager.java b/src/main/java/project/ProjectManager.java index 70ce5ac..55cca47 100644 --- a/src/main/java/project/ProjectManager.java +++ b/src/main/java/project/ProjectManager.java @@ -6,7 +6,6 @@ import java.util.List; import org.apache.commons.io.FileUtils; -import app.Environment; import util.Logger; /** @@ -46,11 +45,10 @@ public class ProjectManager /** * Records that a project was opened (adds or updates its timestamp). - * @param projectPath The path to the project */ - public void recordProjectOpened(File projectPath) + public void recordProjectOpened(Project project) { - repository.updateLastOpened(projectPath); + repository.updateLastOpened(project); } /** @@ -60,7 +58,7 @@ public class ProjectManager */ public void removeFromHistory(Project project) { - repository.removeProject(project.getPath()); + repository.removeProject(project); } /** @@ -70,45 +68,17 @@ public class ProjectManager */ public boolean deleteFromDisk(Project project) { - File projectDir = project.getPath(); + File projectDir = new File(project.getPath()); - // First remove from history - repository.removeProject(projectDir); + repository.removeProject(project); - // Then delete from disk - if (projectDir.exists()) { - try { - FileUtils.deleteDirectory(projectDir); - Logger.log("Deleted project directory: " + projectDir.getAbsolutePath()); - return true; - } - catch (IOException e) { - Logger.logError("Failed to delete project: " + e.getMessage()); - return false; - } + try { + FileUtils.deleteDirectory(projectDir); + return true; } - return true; // Already doesn't exist - } - - /** - * Checks if a directory is a valid Star Rod project. - */ - public boolean isValidProject(File dir) - { - return ProjectValidator.isValidProject(dir); - } - - /** - * Loads a project using Environment.loadProject(). - * @param projectPath The path to the project - * @return true if the project was loaded successfully - */ - public boolean openProject(File projectPath) throws IOException - { - boolean success = Environment.loadProject(projectPath); - if (success) { - recordProjectOpened(projectPath); + catch (IOException e) { + Logger.logError("Failed to delete project: " + e.getMessage()); + return false; } - return success; } } diff --git a/src/main/java/project/ProjectRepository.java b/src/main/java/project/ProjectRepository.java index 30d6324..2ad397d 100644 --- a/src/main/java/project/ProjectRepository.java +++ b/src/main/java/project/ProjectRepository.java @@ -22,13 +22,11 @@ public interface ProjectRepository /** * Removes a project from the repository. - * @param projectPath The path of the project to remove */ - void removeProject(File projectPath); + void removeProject(Project project); /** * Updates the last opened timestamp for a project. - * @param projectPath The path of the project to update - */ - void updateLastOpened(File projectPath); + */ + void updateLastOpened(Project project); } diff --git a/src/main/java/project/ProjectValidator.java b/src/main/java/project/ProjectValidator.java deleted file mode 100644 index 37d9ca1..0000000 --- a/src/main/java/project/ProjectValidator.java +++ /dev/null @@ -1,54 +0,0 @@ -package project; - -import java.io.File; - -import app.Environment; -import app.config.Options; - -/** - * Validates whether a directory is a valid Star Rod project. - * A valid project must have a splat.yaml file in ver/{gameVersion}/. - */ -public class ProjectValidator -{ - private static final String FN_SPLAT = "splat.yaml"; - - /** - * Checks if a directory is a valid Star Rod project. - * @param dir The directory to check - * @return true if the directory contains a valid project structure - */ - public static boolean isValidProject(File dir) - { - if (dir == null || !dir.exists() || !dir.isDirectory()) { - return false; - } - - // Get game version from config (default "us") - String gameVersion = "us"; - if (Environment.mainConfig != null) { - String configVersion = Environment.mainConfig.getString(Options.GameVersion); - if (configVersion != null && !configVersion.isEmpty()) { - gameVersion = configVersion; - } - } - - // Check for splat.yaml in ver/{gameVersion}/ - File versionDir = new File(dir, "ver/" + gameVersion); - if (!versionDir.exists() || !versionDir.isDirectory()) { - return false; - } - - File splatFile = new File(versionDir, FN_SPLAT); - return splatFile.exists(); - } - - /** - * Checks if the current working directory is a valid Star Rod project. - * @return true if cwd contains a valid project structure - */ - public static boolean isCurrentDirectoryProject() - { - return isValidProject(new File(".")); - } -} diff --git a/src/main/java/project/ui/CreateProjectDialog.java b/src/main/java/project/ui/CreateProjectDialog.java new file mode 100644 index 0000000..54ee6aa --- /dev/null +++ b/src/main/java/project/ui/CreateProjectDialog.java @@ -0,0 +1,327 @@ +package project.ui; + +import java.awt.event.WindowEvent; +import java.io.File; +import java.io.IOException; + +import javax.swing.JButton; +import javax.swing.JDialog; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JTextField; +import javax.swing.SwingUtilities; +import javax.swing.WindowConstants; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; + +import app.Environment; +import app.SwingUtils; +import app.config.Options; +import dev.kdl.parse.KdlParseException; +import game.map.editor.ui.dialogs.ChooseDialogResult; +import game.map.editor.ui.dialogs.DirChooser; +import net.miginfocom.swing.MigLayout; +import project.Project; +import util.Logger; + +public class CreateProjectDialog extends JDialog +{ + private Project result = null; + + private JTextField nameField; + private JTextField idField; + private JTextField pathField; + private JButton createButton; + + private boolean idManuallyEdited = false; + private File browsedDir = null; + + /** + * Shows the dialog and returns the created project, or null if cancelled. + */ + public static Project showDialog(JFrame parent) + { + CreateProjectDialog dialog = new CreateProjectDialog(parent); + dialog.setVisible(true); + return dialog.result; + } + + private CreateProjectDialog(JFrame parent) + { + super(parent); + + nameField = new JTextField(); + nameField.setMargin(SwingUtils.TEXTBOX_INSETS); + + idField = new JTextField(); + idField.setMargin(SwingUtils.TEXTBOX_INSETS); + + pathField = new JTextField(); + pathField.setMargin(SwingUtils.TEXTBOX_INSETS); + pathField.setEditable(false); + + // Auto-generate ID from Name + nameField.getDocument().addDocumentListener(new DocumentListener() { + @Override + public void changedUpdate(DocumentEvent e) + { + onNameChanged(); + } + + @Override + public void insertUpdate(DocumentEvent e) + { + onNameChanged(); + } + + @Override + public void removeUpdate(DocumentEvent e) + { + onNameChanged(); + } + }); + + // Track manual edits to ID + idField.getDocument().addDocumentListener(new DocumentListener() { + @Override + public void changedUpdate(DocumentEvent e) + { + onIdChanged(); + } + + @Override + public void insertUpdate(DocumentEvent e) + { + onIdChanged(); + } + + @Override + public void removeUpdate(DocumentEvent e) + { + onIdChanged(); + } + }); + + // Browse button + JButton browseButton = new JButton("Browse..."); + SwingUtils.addBorderPadding(browseButton); + browseButton.addActionListener(e -> browseForPath()); + + // Create and Cancel buttons + createButton = new JButton("Create"); + SwingUtils.addBorderPadding(createButton); + createButton.setEnabled(false); + createButton.addActionListener(e -> createProject()); + + JButton cancelButton = new JButton("Cancel"); + SwingUtils.addBorderPadding(cancelButton); + cancelButton.addActionListener(e -> setVisible(false)); + + setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE); + addWindowListener(new java.awt.event.WindowAdapter() { + @Override + public void windowClosing(WindowEvent e) + { + setVisible(false); + } + }); + + setLayout(new MigLayout("ins 16, wrap", "[grow]")); + + JLabel nameLabel = new JLabel("Name"); + add(nameLabel, ""); + add(nameField, "growx"); + + JLabel idLabel = new JLabel("ID"); + JLabel idDesc = new JLabel("The internal name of your mod. Must be unique between all mods."); + idDesc.setForeground(SwingUtils.getGrayTextColor()); + SwingUtils.setFontSize(idDesc, 11); + add(idLabel, "split 2, gaptop 8"); + add(idDesc, "ax right, pushx"); + add(idField, "growx"); + + JLabel pathLabel = new JLabel("Path"); + add(pathLabel, "gaptop 8"); + add(pathField, "split 2, growx"); + add(browseButton, ""); + + add(new JPanel(), "growx, sg but, split 3, gaptop 12"); + add(createButton, "growx, sg but"); + add(cancelButton, "growx, sg but"); + + updatePath(); + validate_(); + + pack(); + setResizable(false); + + setTitle("New Project"); + setIconImage(Environment.getDefaultIconImage()); + setLocationRelativeTo(parent); + setModal(true); + nameField.requestFocusInWindow(); + + SwingUtilities.invokeLater(() -> { + getRootPane().setDefaultButton(createButton); + nameField.requestFocusInWindow(); + }); + } + + private boolean updatingId = false; + + private void onNameChanged() + { + if (!idManuallyEdited) { + updatingId = true; + idField.setText(toSnakeCase(nameField.getText())); + updatingId = false; + } + updatePath(); + validate_(); + } + + private void onIdChanged() + { + if (!updatingId) { + idManuallyEdited = !idField.getText().isEmpty(); + } + updatePath(); + validate_(); + } + + private void updatePath() + { + String id = getEffectiveId(); + File dir; + + if (browsedDir != null) { + dir = id.isEmpty() ? browsedDir : new File(browsedDir, id); + } + else { + File projectsDir = getDefaultProjectsDir(); + dir = id.isEmpty() ? projectsDir : new File(projectsDir, id); + } + + pathField.setText(abbreviateHome(dir.getAbsolutePath())); + } + + private String getEffectiveId() + { + String id = idField.getText().trim(); + if (id.isEmpty()) + return toSnakeCase(nameField.getText()); + return id; + } + + private File getProjectPath() + { + String id = getEffectiveId(); + if (browsedDir != null) { + return id.isEmpty() ? browsedDir : new File(browsedDir, id); + } + File projectsDir = getDefaultProjectsDir(); + return id.isEmpty() ? projectsDir : new File(projectsDir, id); + } + + private void browseForPath() + { + DirChooser dirChooser = new DirChooser(getDefaultProjectsDir(), "Select Project Location"); + if (dirChooser.prompt() == ChooseDialogResult.APPROVE) { + File selected = dirChooser.getSelectedFile(); + String[] contents = selected.list(); + if (contents != null && contents.length > 0) { + // Directory has files, use it as parent + browsedDir = selected; + } + else { + // Empty directory, use it directly + browsedDir = selected.getParentFile(); + // If the selected dir name matches the id, just use parent as browsedDir + String id = getEffectiveId(); + if (!selected.getName().equals(id)) { + browsedDir = selected; + } + } + updatePath(); + validate_(); + } + } + + private void validate_() + { + String name = nameField.getText().trim(); + String id = getEffectiveId(); + File path = getProjectPath(); + + String error = null; + + if (name.isEmpty()) { + error = "Enter a project name"; + } + else if (id.isEmpty()) { + error = "Enter a project ID"; + } + else if (!id.matches("[a-z]*[a-z0-9_]*")) { + error = "ID must contain only lowercase letters, digits, and underscores, and must start with a letter"; + } + else if (new File(path, "project.kdl").exists()) { + error = "A project already exists at this location"; + } + + if (error != null) { + createButton.setToolTipText(error); + createButton.setEnabled(false); + } + else { + createButton.setToolTipText(null); + createButton.setEnabled(true); + } + } + + private void createProject() + { + File path = getProjectPath(); + String id = getEffectiveId(); + String name = nameField.getText().trim(); + + try { + result = Project.create(path, "blank", id, name); + setVisible(false); + } + catch (IOException | KdlParseException e) { + Logger.logError("Failed to create project: " + e.getMessage()); + Environment.showErrorMessage("Failed to create project", "%s", e.getMessage()); + } + } + + private static File getDefaultProjectsDir() + { + String configured = Environment.mainConfig.getString(Options.ProjectsDir); + if (configured != null && !configured.isEmpty()) { + return new File(configured); + } + + File docs = Environment.getUserDocumentsDir(); + String subdir = Environment.isLinux() ? "starrod" : "Star Rod"; + return new File(docs, subdir); + } + + static String toSnakeCase(String input) + { + return input.trim() + .toLowerCase() + .replaceAll("[^a-z0-9]+", "_") + .replaceAll("_+", "_") + .replaceAll("^_|_$", ""); + } + + private static String abbreviateHome(String path) + { + String home = System.getProperty("user.home"); + if (home != null && path.startsWith(home)) { + return "~" + path.substring(home.length()); + } + return path; + } +} diff --git a/src/main/java/project/ui/ProjectCellRenderer.java b/src/main/java/project/ui/ProjectCellRenderer.java index ae0162c..28379f7 100644 --- a/src/main/java/project/ui/ProjectCellRenderer.java +++ b/src/main/java/project/ui/ProjectCellRenderer.java @@ -72,7 +72,7 @@ public class ProjectCellRenderer extends JPanel implements ListCellRenderer { - // TODO - SwingUtils.getMessageDialog() - .setTitle("Coming Soon") - .setMessage("Project creation is not yet implemented.", - "For now, please clone the papermario or papermario-dx repository manually.") - .setMessageType(JOptionPane.INFORMATION_MESSAGE) - .show(); + Project newProject = CreateProjectDialog.showDialog(this); + if (newProject != null) { + projectManager.recordProjectOpened(newProject); + refreshProjectList(); + updateListFilter(); + list.setSelectedValue(newProject, true); + openSelectedProject(); + } }); JButton browseButton = new JButton("Browse..."); @@ -381,7 +383,7 @@ public class ProjectSwitcherDialog extends StarRodFrame Project project = (Project) element; String filterText = filterTextField.getText().toUpperCase(); String name = project.getName().toUpperCase(); - String path = project.getPath().getAbsolutePath().toUpperCase(); + String path = project.getPath().toUpperCase(); return name.contains(filterText) || path.contains(filterText); }); } @@ -393,23 +395,6 @@ public class ProjectSwitcherDialog extends StarRodFrame return; } - // Validate project - if (!ProjectValidator.isValidProject(selected.getPath())) { - int choice = SwingUtils.getConfirmDialog() - .setTitle("Invalid Project") - .setMessage("This directory is no longer a valid Star Rod project.", - "Would you like to remove it from the list?") - .setOptionsType(JOptionPane.YES_NO_OPTION) - .choose(); - - if (choice == JOptionPane.YES_OPTION) { - projectManager.removeFromHistory(selected); - refreshProjectList(); - updateListFilter(); - } - return; - } - selectedProject = selected; latch.countDown(); dispose(); @@ -419,19 +404,14 @@ public class ProjectSwitcherDialog extends StarRodFrame { if (dirChooser.prompt() == ChooseDialogResult.APPROVE) { File selectedDir = dirChooser.getSelectedFile(); - - if (!ProjectValidator.isValidProject(selectedDir)) { - SwingUtils.getErrorDialog() - .setTitle("Invalid Project") - .setMessage("The selected directory is not a valid Star Rod project.", - "A valid project must have ver/us/splat.yaml") - .show(); + Project newProject; + try { + newProject = new Project(selectedDir); + } catch (IOException | KdlParseException e) { + Environment.showErrorMessage("Failed to open project", "The folder you selected is not a valid project: %s", e.getMessage()); return; } - - // Add to history and select - Project newProject = new Project(selectedDir); - projectManager.recordProjectOpened(selectedDir); + projectManager.recordProjectOpened(newProject); // Refresh and select the new project refreshProjectList(); @@ -451,7 +431,7 @@ public class ProjectSwitcherDialog extends StarRodFrame } int choice = SwingUtils.getConfirmDialog() - .setTitle("Remove Project") + .setTitle("Remove project") .setMessage("Remove \"" + selected.getName() + "\" from the project list?", "The project files will not be deleted.") .setOptionsType(JOptionPane.YES_NO_OPTION) @@ -481,7 +461,7 @@ public class ProjectSwitcherDialog extends StarRodFrame Object[] message = { "WARNING: This will permanently delete all files in:", - selected.getPath().getAbsolutePath(), + selected.getPath(), "", confirmCheck }; diff --git a/src/main/resources/database/templates/blank/.gitignore b/src/main/resources/database/templates/blank/.gitignore new file mode 100644 index 0000000..cc148e4 --- /dev/null +++ b/src/main/resources/database/templates/blank/.gitignore @@ -0,0 +1,20 @@ +# Build artifacts +/build +compile_commands.json +*.z64 +*.n64 + +# IDE settings +.vscode +.zed +.starrod +.claude +.cache + +# Miscellaneous +.DS_Store +Thumbs.db +Desktop.ini +*.lnk +*.log +*.tmp diff --git a/src/main/resources/database/templates/blank/project.kdl b/src/main/resources/database/templates/blank/project.kdl new file mode 100644 index 0000000..31d2085 --- /dev/null +++ b/src/main/resources/database/templates/blank/project.kdl @@ -0,0 +1,6 @@ +id $PROJECT_ID +name $PROJECT_NAME +description $PROJECT_DESCRIPTION +license "CC0" + +engine version="0.0.0" -- 2.51.2