this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

add project switcher / launcher

+1128 -70
+27 -33
src/main/java/app/Environment.java
··· 42 42 import app.config.Options; 43 43 import app.config.Options.Scope; 44 44 import app.input.IOUtils; 45 + import app.project.Project; 46 + import app.project.ProjectManager; 47 + import app.project.ProjectValidator; 48 + import app.project.ui.ProjectSwitcherDialog; 45 49 import assets.AssetExtractor; 46 50 import assets.ExpectedAsset; 47 51 import game.ProjectDatabase; ··· 230 234 231 235 try { 232 236 unpackDatabase(); 233 - File projDir = readMainConfig(); 234 - 235 - if (projDir == null) { 236 - // User declined to select a project directory 237 - exit(); 238 - } 237 + readMainConfig(); 239 238 240 239 boolean logDetails = mainConfig.getBoolean(Options.LogDetails); 241 240 Logger.setDefaultOuputPriority(logDetails ? Priority.DETAIL : Priority.STANDARD); ··· 249 248 if (fromJar && gitBuildTag != null && mainConfig.getBoolean(Options.CheckForUpdates)) 250 249 checkForUpdate(); 251 250 } 251 + 252 + File projDir = chooseProjectDir(); 253 + if (projDir == null) 254 + exit(); 252 255 253 256 LoadingBar.show("Loading Project", true); 254 257 boolean validProject = loadProject(projDir); ··· 424 427 return new File(dotState, "/star-rod/"); 425 428 } 426 429 427 - private static final File readMainConfig() throws IOException 430 + private static final void readMainConfig() throws IOException 428 431 { 429 432 File configDir = getUserConfigDir(); 430 433 ··· 450 453 mainConfig = new Config(configFile, Scope.Main); 451 454 mainConfig.readConfig(); 452 455 } 456 + } 453 457 454 - // if current directory seems to be a decomp project, use it regardless of config 455 - File decompCfg = new File("./ver/us/", FN_SPLAT); 456 - if (decompCfg.exists()) { 458 + private static File chooseProjectDir() throws IOException 459 + { 460 + // if current directory seems to be a decomp project, use it 461 + if (ProjectValidator.isCurrentDirectoryProject()) { 457 462 return new File("."); 458 463 } 459 464 460 - // get project directory from config 461 - String directoryName = mainConfig.getString(Options.ProjPath); 462 - if (directoryName != null) { 463 - File dir; 464 - if (directoryName.startsWith(".")) 465 - dir = new File(codeSource.getParent(), directoryName); 466 - else 467 - dir = new File(directoryName); 468 - 469 - if (dir.exists() && dir.isDirectory()) { 470 - return dir; 471 - } 465 + // show project switcher to select a project 466 + if (commandLine) { 467 + Logger.logError("CWD is not a valid project. Please run Star Rod from a project."); 468 + return null; 472 469 } 473 - 474 - // project directory is missing, prompt to select new one 475 - SwingUtils.getErrorDialog() 476 - .setTitle("Missing Project Directory") 477 - .setMessage("Could not find project directory!", "Please select a new one.") 478 - .show(); 479 - 480 - return promptSelectProject(); 470 + Project selected = ProjectSwitcherDialog.showPrompt(); 471 + if (selected != null) { 472 + return selected.getPath(); 473 + } 474 + return null; 481 475 } 482 476 483 477 public static void promptChangeProject() throws IOException ··· 565 559 }); 566 560 Directories.setProjectDirectory(projectDirectory.getAbsolutePath()); 567 561 568 - mainConfig.setString(Options.ProjPath, projectDirectory.getAbsolutePath()); 569 - mainConfig.saveConfigFile(); 570 - 571 562 readProjectConfig(); 572 563 reloadIcons(); 573 564 ··· 587 578 } 588 579 589 580 AssetExtractor.extractAll(); 581 + 582 + // Record that this project was opened 583 + ProjectManager.getInstance().recordProjectOpened(projectDirectory); 590 584 591 585 return true; 592 586 }
+5 -35
src/main/java/app/StarRodMain.java
··· 29 29 import javax.swing.JScrollBar; 30 30 import javax.swing.JScrollPane; 31 31 import javax.swing.JTextArea; 32 - import javax.swing.JTextField; 33 32 import javax.swing.ScrollPaneConstants; 34 33 import javax.swing.SwingConstants; 35 34 import javax.swing.SwingUtilities; ··· 103 102 setMinimumSize(new Dimension(480, 32)); 104 103 setLocationRelativeTo(null); 105 104 106 - JTextField projectDirField = new JTextField(); 107 - projectDirField.setMinimumSize(new Dimension(64, 24)); 108 - projectDirField.setText(Environment.getProjectDirectory().getAbsolutePath()); 109 - 110 - projectDirField.addActionListener((e) -> { 111 - File choice = new File(projectDirField.getText()); 112 - if (choice != null) { 113 - try { 114 - boolean validProject = Environment.loadProject(choice); 115 - if (!validProject) { 116 - projectDirField.setText(Environment.getProjectDirectory().getAbsolutePath()); 117 - } 118 - } 119 - catch (Throwable t) { 120 - displayStackTrace(t); 121 - } 122 - } 123 - }); 124 - 125 - JButton chooseFolderButton = new JButton("Choose"); 126 - chooseFolderButton.addActionListener(e -> { 127 - try { 128 - Environment.promptChangeProject(); 129 - projectDirField.setText(Environment.getProjectDirectory().getAbsolutePath()); 130 - } 131 - catch (Throwable t) { 132 - displayStackTrace(t); 133 - } 134 - }); 135 - buttons.add(chooseFolderButton); 105 + // Display current project path (read-only, restart app to change projects) 106 + JLabel projectPathLabel = new JLabel(Environment.getProjectDirectory().getAbsolutePath()); 107 + SwingUtils.setFontSize(projectPathLabel, 11); 136 108 137 109 JButton mapEditorButton = new JButton("Map Editor"); 138 110 trySetIcon(mapEditorButton, ExpectedAsset.ICON_MAP_EDITOR); ··· 289 261 }); 290 262 291 263 setLayout(new MigLayout("fillx, ins 16 16 16 16, wrap 2, hidemode 3", "[sg main, grow]8[sg main, grow]")); 292 - SwingUtils.addBorderPadding(projectDirField); 293 264 294 - add(new JLabel("Project:"), "sgy field, span, split 3"); 295 - add(projectDirField, "pushx, growx, sgy field"); 296 - add(chooseFolderButton, "wrap, sgy field, gapbottom 8"); 265 + add(new JLabel("Project:"), "span, split 2"); 266 + add(projectPathLabel, "pushx, growx, wrap, gapbottom 8"); 297 267 298 268 add(mapEditorButton, "grow"); 299 269 add(spriteEditorButton, "grow");
+1 -2
src/main/java/app/config/Options.java
··· 16 16 public enum Options 17 17 { 18 18 // @formatter:off 19 - // options for main.cfg, mostly to keep track of user directories 20 - ProjPath (true, Scope.Main, Type.String, "ProjPath", null), 19 + // options for main.cfg 21 20 GameVersion (true, Scope.Main, Type.String, "GameVersion", "us"), 22 21 23 22 LogDetails (true, Scope.Main, Type.Boolean, "LogDetails", "false"),
+162
src/main/java/app/project/JsonProjectRepository.java
··· 1 + package app.project; 2 + 3 + import java.io.File; 4 + import java.io.FileReader; 5 + import java.io.FileWriter; 6 + import java.io.IOException; 7 + import java.lang.reflect.Type; 8 + import java.util.ArrayList; 9 + import java.util.Collections; 10 + import java.util.Iterator; 11 + import java.util.List; 12 + 13 + import com.google.gson.Gson; 14 + import com.google.gson.GsonBuilder; 15 + import com.google.gson.reflect.TypeToken; 16 + 17 + import app.Environment; 18 + import util.Logger; 19 + 20 + /** 21 + * JSON-based implementation of ProjectRepository. 22 + * Stores projects in projects.json in the user config directory. 23 + */ 24 + public class JsonProjectRepository implements ProjectRepository 25 + { 26 + private static final String PROJECTS_FILE = "projects.json"; 27 + 28 + private final File projectsFile; 29 + private final Gson gson; 30 + 31 + public JsonProjectRepository() 32 + { 33 + this.projectsFile = new File(Environment.getUserConfigDir(), PROJECTS_FILE); 34 + this.gson = new GsonBuilder() 35 + .setPrettyPrinting() 36 + .create(); 37 + } 38 + 39 + @Override 40 + public synchronized List<Project> getAllProjects() 41 + { 42 + List<ProjectData> dataList = loadProjectData(); 43 + List<Project> projects = new ArrayList<>(); 44 + 45 + // Convert to Project objects, filtering out invalid entries 46 + Iterator<ProjectData> iter = dataList.iterator(); 47 + boolean modified = false; 48 + 49 + while (iter.hasNext()) { 50 + ProjectData data = iter.next(); 51 + File path = new File(data.path); 52 + 53 + // Remove invalid entries 54 + if (!path.exists()) { 55 + iter.remove(); 56 + modified = true; 57 + continue; 58 + } 59 + 60 + projects.add(new Project(path, data.lastOpened)); 61 + } 62 + 63 + // Save if we removed any invalid entries 64 + if (modified) { 65 + saveProjectData(dataList); 66 + } 67 + 68 + // Sort by last opened (most recent first) 69 + Collections.sort(projects); 70 + return projects; 71 + } 72 + 73 + @Override 74 + public synchronized void addProject(Project project) 75 + { 76 + List<ProjectData> dataList = loadProjectData(); 77 + 78 + // Remove existing entry with same path (will be re-added with new timestamp) 79 + String absolutePath = project.getPath().getAbsolutePath(); 80 + dataList.removeIf(data -> data.path.equals(absolutePath)); 81 + 82 + // Add new entry 83 + ProjectData newData = new ProjectData(); 84 + newData.path = absolutePath; 85 + newData.lastOpened = project.getLastOpened(); 86 + dataList.add(0, newData); // Add to beginning (most recent) 87 + 88 + saveProjectData(dataList); 89 + } 90 + 91 + @Override 92 + public synchronized void removeProject(File projectPath) 93 + { 94 + List<ProjectData> dataList = loadProjectData(); 95 + String absolutePath = projectPath.getAbsolutePath(); 96 + dataList.removeIf(data -> data.path.equals(absolutePath)); 97 + saveProjectData(dataList); 98 + } 99 + 100 + @Override 101 + public synchronized void updateLastOpened(File projectPath) 102 + { 103 + List<ProjectData> dataList = loadProjectData(); 104 + String absolutePath = projectPath.getAbsolutePath(); 105 + 106 + for (ProjectData data : dataList) { 107 + if (data.path.equals(absolutePath)) { 108 + data.lastOpened = System.currentTimeMillis(); 109 + saveProjectData(dataList); 110 + return; 111 + } 112 + } 113 + 114 + // Project not found, add it 115 + ProjectData newData = new ProjectData(); 116 + newData.path = absolutePath; 117 + newData.lastOpened = System.currentTimeMillis(); 118 + dataList.add(0, newData); 119 + saveProjectData(dataList); 120 + } 121 + 122 + private List<ProjectData> loadProjectData() 123 + { 124 + if (!projectsFile.exists()) { 125 + return new ArrayList<>(); 126 + } 127 + 128 + try (FileReader reader = new FileReader(projectsFile)) { 129 + Type listType = new TypeToken<List<ProjectData>>() {}.getType(); 130 + List<ProjectData> data = gson.fromJson(reader, listType); 131 + return data != null ? data : new ArrayList<>(); 132 + } 133 + catch (IOException e) { 134 + Logger.logError("Failed to read projects file: " + e.getMessage()); 135 + return new ArrayList<>(); 136 + } 137 + } 138 + 139 + private void saveProjectData(List<ProjectData> dataList) 140 + { 141 + try { 142 + // Ensure parent directory exists 143 + projectsFile.getParentFile().mkdirs(); 144 + 145 + try (FileWriter writer = new FileWriter(projectsFile)) { 146 + gson.toJson(dataList, writer); 147 + } 148 + } 149 + catch (IOException e) { 150 + Logger.logError("Failed to save projects file: " + e.getMessage()); 151 + } 152 + } 153 + 154 + /** 155 + * Internal data class for JSON serialization. 156 + */ 157 + private static class ProjectData 158 + { 159 + String path; 160 + long lastOpened; 161 + } 162 + }
+79
src/main/java/app/project/Project.java
··· 1 + package app.project; 2 + 3 + import java.io.File; 4 + import java.util.Objects; 5 + 6 + /** 7 + * Immutable data class representing a Star Rod project. 8 + * Stores the project path and last opened timestamp. 9 + */ 10 + public class Project implements Comparable<Project> 11 + { 12 + private final File path; 13 + private final long lastOpened; 14 + 15 + public Project(File path, long lastOpened) 16 + { 17 + Objects.requireNonNull(path, "Project path cannot be null"); 18 + this.path = path.getAbsoluteFile(); 19 + this.lastOpened = lastOpened; 20 + } 21 + 22 + public Project(File path) 23 + { 24 + this(path, System.currentTimeMillis()); 25 + } 26 + 27 + public File getPath() 28 + { 29 + return path; 30 + } 31 + 32 + public String getName() 33 + { 34 + return path.getName(); 35 + } 36 + 37 + public long getLastOpened() 38 + { 39 + return lastOpened; 40 + } 41 + 42 + /** 43 + * Creates a new Project instance with updated lastOpened timestamp. 44 + */ 45 + public Project withLastOpened(long timestamp) 46 + { 47 + return new Project(path, timestamp); 48 + } 49 + 50 + @Override 51 + public int compareTo(Project other) 52 + { 53 + // Sort by lastOpened descending (most recent first) 54 + return Long.compare(other.lastOpened, this.lastOpened); 55 + } 56 + 57 + @Override 58 + public boolean equals(Object obj) 59 + { 60 + if (this == obj) 61 + return true; 62 + if (obj == null || getClass() != obj.getClass()) 63 + return false; 64 + Project other = (Project) obj; 65 + return path.equals(other.path); 66 + } 67 + 68 + @Override 69 + public int hashCode() 70 + { 71 + return path.hashCode(); 72 + } 73 + 74 + @Override 75 + public String toString() 76 + { 77 + return getName() + " (" + path.getAbsolutePath() + ")"; 78 + } 79 + }
+114
src/main/java/app/project/ProjectManager.java
··· 1 + package app.project; 2 + 3 + import java.io.File; 4 + import java.io.IOException; 5 + import java.util.List; 6 + 7 + import org.apache.commons.io.FileUtils; 8 + 9 + import app.Environment; 10 + import util.Logger; 11 + 12 + /** 13 + * Use case class for project operations. 14 + * Orchestrates project repository operations and integrates with Environment. 15 + */ 16 + public class ProjectManager 17 + { 18 + private static ProjectManager instance; 19 + 20 + private final ProjectRepository repository; 21 + 22 + private ProjectManager(ProjectRepository repository) 23 + { 24 + this.repository = repository; 25 + } 26 + 27 + /** 28 + * Gets the singleton instance of ProjectManager. 29 + */ 30 + public static synchronized ProjectManager getInstance() 31 + { 32 + if (instance == null) { 33 + instance = new ProjectManager(new JsonProjectRepository()); 34 + } 35 + return instance; 36 + } 37 + 38 + /** 39 + * Gets all recent projects, sorted by last opened (most recent first). 40 + * Invalid projects (non-existent paths) are automatically removed. 41 + */ 42 + public List<Project> getRecentProjects() 43 + { 44 + return repository.getAllProjects(); 45 + } 46 + 47 + /** 48 + * Records that a project was opened (adds or updates its timestamp). 49 + * @param projectPath The path to the project 50 + */ 51 + public void recordProjectOpened(File projectPath) 52 + { 53 + repository.updateLastOpened(projectPath); 54 + } 55 + 56 + /** 57 + * Removes a project from the recent projects list. 58 + * Does NOT delete files from disk. 59 + * @param project The project to remove 60 + */ 61 + public void removeFromHistory(Project project) 62 + { 63 + repository.removeProject(project.getPath()); 64 + } 65 + 66 + /** 67 + * Deletes a project from disk and removes it from the history. 68 + * @param project The project to delete 69 + * @return true if deletion was successful, false otherwise 70 + */ 71 + public boolean deleteFromDisk(Project project) 72 + { 73 + File projectDir = project.getPath(); 74 + 75 + // First remove from history 76 + repository.removeProject(projectDir); 77 + 78 + // Then delete from disk 79 + if (projectDir.exists()) { 80 + try { 81 + FileUtils.deleteDirectory(projectDir); 82 + Logger.log("Deleted project directory: " + projectDir.getAbsolutePath()); 83 + return true; 84 + } 85 + catch (IOException e) { 86 + Logger.logError("Failed to delete project: " + e.getMessage()); 87 + return false; 88 + } 89 + } 90 + return true; // Already doesn't exist 91 + } 92 + 93 + /** 94 + * Checks if a directory is a valid Star Rod project. 95 + */ 96 + public boolean isValidProject(File dir) 97 + { 98 + return ProjectValidator.isValidProject(dir); 99 + } 100 + 101 + /** 102 + * Loads a project using Environment.loadProject(). 103 + * @param projectPath The path to the project 104 + * @return true if the project was loaded successfully 105 + */ 106 + public boolean openProject(File projectPath) throws IOException 107 + { 108 + boolean success = Environment.loadProject(projectPath); 109 + if (success) { 110 + recordProjectOpened(projectPath); 111 + } 112 + return success; 113 + } 114 + }
+34
src/main/java/app/project/ProjectRepository.java
··· 1 + package app.project; 2 + 3 + import java.io.File; 4 + import java.util.List; 5 + 6 + /** 7 + * Interface for project persistence operations. 8 + */ 9 + public interface ProjectRepository 10 + { 11 + /** 12 + * Gets all projects sorted by last opened (most recent first). 13 + * @return List of projects, or empty list if none exist 14 + */ 15 + List<Project> getAllProjects(); 16 + 17 + /** 18 + * Adds a project to the repository or updates its timestamp if it already exists. 19 + * @param project The project to add or update 20 + */ 21 + void addProject(Project project); 22 + 23 + /** 24 + * Removes a project from the repository. 25 + * @param projectPath The path of the project to remove 26 + */ 27 + void removeProject(File projectPath); 28 + 29 + /** 30 + * Updates the last opened timestamp for a project. 31 + * @param projectPath The path of the project to update 32 + */ 33 + void updateLastOpened(File projectPath); 34 + }
+54
src/main/java/app/project/ProjectValidator.java
··· 1 + package app.project; 2 + 3 + import java.io.File; 4 + 5 + import app.Environment; 6 + import app.config.Options; 7 + 8 + /** 9 + * Validates whether a directory is a valid Star Rod project. 10 + * A valid project must have a splat.yaml file in ver/{gameVersion}/. 11 + */ 12 + public class ProjectValidator 13 + { 14 + private static final String FN_SPLAT = "splat.yaml"; 15 + 16 + /** 17 + * Checks if a directory is a valid Star Rod project. 18 + * @param dir The directory to check 19 + * @return true if the directory contains a valid project structure 20 + */ 21 + public static boolean isValidProject(File dir) 22 + { 23 + if (dir == null || !dir.exists() || !dir.isDirectory()) { 24 + return false; 25 + } 26 + 27 + // Get game version from config (default "us") 28 + String gameVersion = "us"; 29 + if (Environment.mainConfig != null) { 30 + String configVersion = Environment.mainConfig.getString(Options.GameVersion); 31 + if (configVersion != null && !configVersion.isEmpty()) { 32 + gameVersion = configVersion; 33 + } 34 + } 35 + 36 + // Check for splat.yaml in ver/{gameVersion}/ 37 + File versionDir = new File(dir, "ver/" + gameVersion); 38 + if (!versionDir.exists() || !versionDir.isDirectory()) { 39 + return false; 40 + } 41 + 42 + File splatFile = new File(versionDir, FN_SPLAT); 43 + return splatFile.exists(); 44 + } 45 + 46 + /** 47 + * Checks if the current working directory is a valid Star Rod project. 48 + * @return true if cwd contains a valid project structure 49 + */ 50 + public static boolean isCurrentDirectoryProject() 51 + { 52 + return isValidProject(new File(".")); 53 + } 54 + }
+129
src/main/java/app/project/ui/ProjectCellRenderer.java
··· 1 + package app.project.ui; 2 + 3 + import java.awt.Component; 4 + import java.time.Instant; 5 + import java.time.LocalDateTime; 6 + import java.time.ZoneId; 7 + import java.time.temporal.ChronoUnit; 8 + 9 + import javax.swing.BorderFactory; 10 + import javax.swing.JLabel; 11 + import javax.swing.JList; 12 + import javax.swing.JPanel; 13 + import javax.swing.ListCellRenderer; 14 + import javax.swing.SwingConstants; 15 + 16 + import app.SwingUtils; 17 + import app.project.Project; 18 + import net.miginfocom.swing.MigLayout; 19 + 20 + /** 21 + * Cell renderer for projects in the project list. 22 + * Displays project name (bold), path, and last opened time. 23 + */ 24 + public class ProjectCellRenderer extends JPanel implements ListCellRenderer<Project> 25 + { 26 + private final JLabel nameLabel; 27 + private final JLabel pathLabel; 28 + private final JLabel timeLabel; 29 + 30 + public ProjectCellRenderer() 31 + { 32 + nameLabel = new JLabel(""); 33 + SwingUtils.setFontSize(nameLabel, 14); 34 + 35 + pathLabel = new JLabel(""); 36 + SwingUtils.setFontSize(pathLabel, 11); 37 + 38 + timeLabel = new JLabel(""); 39 + SwingUtils.setFontSize(timeLabel, 11); 40 + 41 + setLayout(new MigLayout("ins 0, fillx", "[grow]8[120!]")); 42 + add(nameLabel, "wrap"); 43 + add(pathLabel, ""); 44 + add(timeLabel, "align right"); 45 + 46 + setOpaque(true); 47 + setBorder(BorderFactory.createEmptyBorder(4, 8, 4, 8)); 48 + } 49 + 50 + @Override 51 + public Component getListCellRendererComponent( 52 + JList<? extends Project> list, 53 + Project project, 54 + int index, 55 + boolean isSelected, 56 + boolean cellHasFocus) 57 + { 58 + if (isSelected) { 59 + setBackground(list.getSelectionBackground()); 60 + setForeground(list.getSelectionForeground()); 61 + nameLabel.setForeground(list.getSelectionForeground()); 62 + pathLabel.setForeground(list.getSelectionForeground()); 63 + timeLabel.setForeground(list.getSelectionForeground()); 64 + } 65 + else { 66 + setBackground(list.getBackground()); 67 + setForeground(list.getForeground()); 68 + nameLabel.setForeground(list.getForeground()); 69 + pathLabel.setForeground(SwingUtils.getGrayTextColor()); 70 + timeLabel.setForeground(SwingUtils.getGrayTextColor()); 71 + } 72 + 73 + if (project != null) { 74 + nameLabel.setText(project.getName()); 75 + pathLabel.setText(project.getPath().getAbsolutePath()); 76 + timeLabel.setText(formatRelativeTime(project.getLastOpened())); 77 + } 78 + else { 79 + nameLabel.setText("ERROR"); 80 + pathLabel.setText(""); 81 + timeLabel.setText(""); 82 + nameLabel.setForeground(SwingUtils.getRedTextColor()); 83 + } 84 + 85 + return this; 86 + } 87 + 88 + /** 89 + * Formats a timestamp as relative time (e.g., "2 hours ago", "Yesterday"). 90 + */ 91 + private static String formatRelativeTime(long timestamp) 92 + { 93 + LocalDateTime time = LocalDateTime.ofInstant( 94 + Instant.ofEpochMilli(timestamp), ZoneId.systemDefault()); 95 + LocalDateTime now = LocalDateTime.now(); 96 + 97 + long minutes = ChronoUnit.MINUTES.between(time, now); 98 + long hours = ChronoUnit.HOURS.between(time, now); 99 + long days = ChronoUnit.DAYS.between(time, now); 100 + 101 + if (minutes < 1) { 102 + return "Just now"; 103 + } 104 + else if (minutes < 60) { 105 + return minutes + (minutes == 1 ? " minute ago" : " minutes ago"); 106 + } 107 + else if (hours < 24) { 108 + return hours + (hours == 1 ? " hour ago" : " hours ago"); 109 + } 110 + else if (days == 1) { 111 + return "Yesterday"; 112 + } 113 + else if (days < 7) { 114 + return days + " days ago"; 115 + } 116 + else if (days < 30) { 117 + long weeks = days / 7; 118 + return weeks + (weeks == 1 ? " week ago" : " weeks ago"); 119 + } 120 + else if (days < 365) { 121 + long months = days / 30; 122 + return months + (months == 1 ? " month ago" : " months ago"); 123 + } 124 + else { 125 + long years = days / 365; 126 + return years + (years == 1 ? " year ago" : " years ago"); 127 + } 128 + } 129 + }
+523
src/main/java/app/project/ui/ProjectSwitcherDialog.java
··· 1 + package app.project.ui; 2 + 3 + import java.awt.CardLayout; 4 + import java.awt.Color; 5 + import java.awt.Desktop; 6 + import java.awt.Dimension; 7 + import java.awt.Font; 8 + import java.awt.Toolkit; 9 + import java.awt.event.ActionEvent; 10 + import java.awt.event.KeyAdapter; 11 + import java.awt.event.KeyEvent; 12 + import java.awt.event.MouseAdapter; 13 + import java.awt.event.MouseEvent; 14 + import java.awt.event.WindowAdapter; 15 + import java.awt.event.WindowEvent; 16 + import java.io.File; 17 + import java.net.URI; 18 + import java.util.List; 19 + import java.util.concurrent.CountDownLatch; 20 + 21 + import javax.swing.AbstractAction; 22 + import javax.swing.BorderFactory; 23 + import javax.swing.ButtonGroup; 24 + import javax.swing.DefaultListModel; 25 + import javax.swing.JButton; 26 + import javax.swing.JCheckBox; 27 + import javax.swing.JComboBox; 28 + import javax.swing.JComponent; 29 + import javax.swing.JFrame; 30 + import javax.swing.JLabel; 31 + import javax.swing.JList; 32 + import javax.swing.JMenuItem; 33 + import javax.swing.JOptionPane; 34 + import javax.swing.JPanel; 35 + import javax.swing.JPopupMenu; 36 + import javax.swing.JScrollPane; 37 + import javax.swing.JTextField; 38 + import javax.swing.JToggleButton; 39 + import javax.swing.KeyStroke; 40 + import javax.swing.ListSelectionModel; 41 + import javax.swing.ScrollPaneConstants; 42 + import javax.swing.SwingConstants; 43 + import javax.swing.SwingUtilities; 44 + import javax.swing.event.DocumentEvent; 45 + import javax.swing.event.DocumentListener; 46 + 47 + import app.Environment; 48 + import app.StarRodFrame; 49 + import app.SwingUtils; 50 + import app.Themes; 51 + import app.Themes.Theme; 52 + import app.config.Options; 53 + import app.project.Project; 54 + import app.project.ProjectManager; 55 + import app.project.ProjectValidator; 56 + import game.map.editor.ui.dialogs.ChooseDialogResult; 57 + import game.map.editor.ui.dialogs.DirChooser; 58 + import net.miginfocom.swing.MigLayout; 59 + import util.Logger; 60 + import util.ui.FilteredListModel; 61 + 62 + /** 63 + * Window for selecting and managing Star Rod projects. 64 + * Similar to Godot's project manager. 65 + */ 66 + public class ProjectSwitcherDialog extends StarRodFrame 67 + { 68 + private final String TAB_PROJECTS = "Projects"; 69 + 70 + private JList<Project> list; 71 + private DefaultListModel<Project> listModel; 72 + private FilteredListModel<Project> filteredListModel; 73 + private JTextField filterTextField; 74 + private JPopupMenu contextMenu; 75 + private CardLayout cardLayout; 76 + private JPanel contentPanel; 77 + 78 + private final ProjectManager projectManager; 79 + private final DirChooser dirChooser; 80 + 81 + private final CountDownLatch latch = new CountDownLatch(1); 82 + private Project selectedProject = null; 83 + 84 + /** 85 + * Shows the project switcher and returns the selected project. 86 + * Blocks until the user makes a selection or closes the window. 87 + * @return The selected project, or null if cancelled 88 + */ 89 + public static Project showPrompt() 90 + { 91 + ProjectSwitcherDialog window = new ProjectSwitcherDialog(); 92 + window.setVisible(true); 93 + 94 + try { 95 + window.latch.await(); 96 + } 97 + catch (InterruptedException e) { 98 + Thread.currentThread().interrupt(); 99 + } 100 + 101 + return window.selectedProject; 102 + } 103 + 104 + private ProjectSwitcherDialog() 105 + { 106 + super("Star Rod Launcher"); 107 + setDefaultCloseOperation(DO_NOTHING_ON_CLOSE); 108 + setIconImage(Environment.getDefaultIconImage()); 109 + 110 + addWindowListener(new WindowAdapter() { 111 + @Override 112 + public void windowClosing(WindowEvent e) 113 + { 114 + selectedProject = null; 115 + latch.countDown(); 116 + dispose(); 117 + } 118 + }); 119 + 120 + projectManager = ProjectManager.getInstance(); 121 + dirChooser = new DirChooser(new File("."), "Select Project Directory"); 122 + 123 + // Move title label to top header 124 + JLabel titleLabel = new JLabel("Star Rod"); 125 + titleLabel.setFont(titleLabel.getFont().deriveFont(Font.BOLD, 24f)); 126 + titleLabel.setHorizontalAlignment(SwingConstants.CENTER); 127 + 128 + // === SIDEBAR === 129 + JPanel sidebar = new JPanel(new MigLayout("ins 16, fill, wrap, gapy 4", "[grow]")); 130 + sidebar.setPreferredSize(new Dimension(160, 0)); 131 + 132 + // Tab buttons 133 + ButtonGroup tabGroup = new ButtonGroup(); 134 + 135 + JToggleButton projectsTab = createTabButton("Projects"); 136 + tabGroup.add(projectsTab); 137 + sidebar.add(projectsTab, "growx"); 138 + 139 + // Spacer to push theme chooser to bottom 140 + sidebar.add(new JLabel(), "grow, pushy"); 141 + 142 + // Theme chooser 143 + JLabel themeLabel = new JLabel("Theme"); 144 + SwingUtils.setFontSize(themeLabel, 11); 145 + sidebar.add(themeLabel, ""); 146 + 147 + JComboBox<Theme> themeCombo = new JComboBox<>(); 148 + for (Theme theme : Themes.getThemes()) { 149 + themeCombo.addItem(theme); 150 + } 151 + themeCombo.setSelectedItem(Themes.getCurrentTheme()); 152 + themeCombo.setRenderer((list, value, index, isSelected, cellHasFocus) -> { 153 + JLabel label = new JLabel(value != null ? value.name : ""); 154 + label.setOpaque(true); 155 + if (isSelected) { 156 + label.setBackground(list.getSelectionBackground()); 157 + label.setForeground(list.getSelectionForeground()); 158 + } 159 + else { 160 + label.setBackground(list.getBackground()); 161 + label.setForeground(list.getForeground()); 162 + } 163 + label.setBorder(BorderFactory.createEmptyBorder(4, 8, 4, 8)); 164 + return label; 165 + }); 166 + themeCombo.addActionListener(e -> { 167 + Theme selected = (Theme) themeCombo.getSelectedItem(); 168 + if (selected != null) { 169 + Themes.setTheme(selected); 170 + Environment.mainConfig.setString(Options.Theme, selected.key); 171 + Environment.mainConfig.saveConfigFile(); 172 + SwingUtilities.updateComponentTreeUI(this); 173 + } 174 + }); 175 + sidebar.add(themeCombo, "growx"); 176 + 177 + // === CONTENT AREA === 178 + cardLayout = new CardLayout(); 179 + contentPanel = new JPanel(cardLayout); 180 + 181 + // Projects panel 182 + JPanel projectsPanel = createProjectsPanel(); 183 + contentPanel.add(projectsPanel, TAB_PROJECTS); 184 + 185 + // Tab button actions 186 + projectsTab.addActionListener(e -> cardLayout.show(contentPanel, TAB_PROJECTS)); 187 + // Eventually add other tabs like Templates and Learn 188 + 189 + // Select projects tab by default 190 + projectsTab.setSelected(true); 191 + 192 + // === MAIN LAYOUT === 193 + // Add a top row for the title, then the main content row 194 + setLayout(new MigLayout("ins 0, fill", "[160][grow]", "[]0[grow]")); 195 + add(titleLabel, "span 2, center, wrap"); 196 + add(sidebar, "growy"); 197 + add(contentPanel, "grow"); 198 + 199 + setPreferredSize(new Dimension(800, 500)); 200 + setMinimumSize(new Dimension(600, 400)); 201 + pack(); 202 + setLocationRelativeTo(null); 203 + 204 + // Cmd+K / Ctrl+K to focus search 205 + int shortcutMask = Toolkit.getDefaultToolkit().getMenuShortcutKeyMaskEx(); 206 + KeyStroke searchKey = KeyStroke.getKeyStroke(KeyEvent.VK_K, shortcutMask); 207 + getRootPane().getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(searchKey, "focusSearch"); 208 + getRootPane().getActionMap().put("focusSearch", new AbstractAction() { 209 + @Override 210 + public void actionPerformed(ActionEvent e) 211 + { 212 + filterTextField.requestFocusInWindow(); 213 + filterTextField.selectAll(); 214 + } 215 + }); 216 + } 217 + 218 + private JToggleButton createTabButton(String text) 219 + { 220 + JToggleButton button = new JToggleButton(text); 221 + button.setHorizontalAlignment(SwingConstants.LEFT); 222 + button.setFocusPainted(false); 223 + button.putClientProperty("JButton.buttonType", "borderless"); 224 + return button; 225 + } 226 + 227 + private JPanel createProjectsPanel() 228 + { 229 + JPanel panel = new JPanel(new MigLayout("ins 16, fill, wrap")); 230 + 231 + // Load projects 232 + listModel = new DefaultListModel<>(); 233 + refreshProjectList(); 234 + 235 + // Create list 236 + list = new JList<>(); 237 + list.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); 238 + list.setCellRenderer(new ProjectCellRenderer()); 239 + list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); 240 + 241 + filteredListModel = new FilteredListModel<>(listModel); 242 + list.setModel(filteredListModel); 243 + 244 + // Context menu for list items 245 + contextMenu = new JPopupMenu(); 246 + 247 + JMenuItem removeItem = new JMenuItem("Remove from List"); 248 + removeItem.addActionListener(e -> removeSelectedProject()); 249 + contextMenu.add(removeItem); 250 + 251 + JMenuItem deleteItem = new JMenuItem("Delete Permanently"); 252 + deleteItem.addActionListener(e -> deleteSelectedProjectFromDisk()); 253 + contextMenu.add(deleteItem); 254 + 255 + // Double-click to open, right-click for context menu 256 + list.addMouseListener(new MouseAdapter() { 257 + @Override 258 + public void mouseClicked(MouseEvent e) 259 + { 260 + if (SwingUtilities.isLeftMouseButton(e) && e.getClickCount() == 2 && list.getSelectedValue() != null) { 261 + openSelectedProject(); 262 + } 263 + } 264 + 265 + @Override 266 + public void mousePressed(MouseEvent e) 267 + { 268 + handlePopup(e); 269 + } 270 + 271 + @Override 272 + public void mouseReleased(MouseEvent e) 273 + { 274 + handlePopup(e); 275 + } 276 + 277 + private void handlePopup(MouseEvent e) 278 + { 279 + if (e.isPopupTrigger()) { 280 + int index = list.locationToIndex(e.getPoint()); 281 + if (index >= 0) { 282 + list.setSelectedIndex(index); 283 + contextMenu.show(list, e.getX(), e.getY()); 284 + } 285 + } 286 + } 287 + }); 288 + 289 + // Delete key to remove, Enter to open 290 + list.addKeyListener(new KeyAdapter() { 291 + @Override 292 + public void keyPressed(KeyEvent e) 293 + { 294 + if (e.getKeyCode() == KeyEvent.VK_DELETE) { 295 + removeSelectedProject(); 296 + } 297 + else if (e.getKeyCode() == KeyEvent.VK_ENTER && list.getSelectedValue() != null) { 298 + openSelectedProject(); 299 + } 300 + } 301 + }); 302 + 303 + // Filter text field 304 + filterTextField = new JTextField(20); 305 + filterTextField.setMargin(SwingUtils.TEXTBOX_INSETS); 306 + filterTextField.putClientProperty("JTextField.placeholderText", "Search..."); 307 + filterTextField.getDocument().addDocumentListener(new DocumentListener() { 308 + @Override 309 + public void changedUpdate(DocumentEvent e) 310 + { 311 + updateListFilter(); 312 + } 313 + 314 + @Override 315 + public void insertUpdate(DocumentEvent e) 316 + { 317 + updateListFilter(); 318 + } 319 + 320 + @Override 321 + public void removeUpdate(DocumentEvent e) 322 + { 323 + updateListFilter(); 324 + } 325 + }); 326 + SwingUtils.addBorderPadding(filterTextField); 327 + 328 + JScrollPane listScrollPane = new JScrollPane(list); 329 + listScrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); 330 + listScrollPane.setWheelScrollingEnabled(true); 331 + 332 + // Buttons 333 + JButton createButton = new JButton("New Project"); 334 + createButton.addActionListener(e -> { 335 + // TODO 336 + SwingUtils.getMessageDialog() 337 + .setTitle("Coming Soon") 338 + .setMessage("Project creation is not yet implemented.", 339 + "For now, please clone the papermario or papermario-dx repository manually.") 340 + .setMessageType(JOptionPane.INFORMATION_MESSAGE) 341 + .show(); 342 + }); 343 + 344 + JButton browseButton = new JButton("Browse..."); 345 + browseButton.addActionListener(e -> browseForProject()); 346 + 347 + // Layout 348 + panel.add(filterTextField, "split 3, growx, pushx"); 349 + panel.add(browseButton, "sg but"); 350 + panel.add(createButton, "sg but, wrap"); 351 + 352 + panel.add(listScrollPane, "grow, push"); 353 + 354 + // Set New Project as the default button so it looks like the primary action 355 + SwingUtilities.invokeLater(() -> getRootPane().setDefaultButton(createButton)); 356 + 357 + return panel; 358 + } 359 + 360 + private void openURL(String url) 361 + { 362 + try { 363 + Desktop.getDesktop().browse(new URI(url)); 364 + } 365 + catch (Exception ex) { 366 + Logger.logError("Failed to open URL: " + url); 367 + } 368 + } 369 + 370 + private void refreshProjectList() 371 + { 372 + listModel.clear(); 373 + List<Project> projects = projectManager.getRecentProjects(); 374 + for (Project project : projects) { 375 + listModel.addElement(project); 376 + } 377 + } 378 + 379 + private void updateListFilter() 380 + { 381 + filteredListModel.setFilter(element -> { 382 + Project project = (Project) element; 383 + String filterText = filterTextField.getText().toUpperCase(); 384 + String name = project.getName().toUpperCase(); 385 + String path = project.getPath().getAbsolutePath().toUpperCase(); 386 + return name.contains(filterText) || path.contains(filterText); 387 + }); 388 + } 389 + 390 + private void openSelectedProject() 391 + { 392 + Project selected = list.getSelectedValue(); 393 + if (selected == null) { 394 + return; 395 + } 396 + 397 + // Validate project 398 + if (!ProjectValidator.isValidProject(selected.getPath())) { 399 + int choice = SwingUtils.getConfirmDialog() 400 + .setTitle("Invalid Project") 401 + .setMessage("This directory is no longer a valid Star Rod project.", 402 + "Would you like to remove it from the list?") 403 + .setOptionsType(JOptionPane.YES_NO_OPTION) 404 + .choose(); 405 + 406 + if (choice == JOptionPane.YES_OPTION) { 407 + projectManager.removeFromHistory(selected); 408 + refreshProjectList(); 409 + updateListFilter(); 410 + } 411 + return; 412 + } 413 + 414 + selectedProject = selected; 415 + latch.countDown(); 416 + dispose(); 417 + } 418 + 419 + private void browseForProject() 420 + { 421 + if (dirChooser.prompt() == ChooseDialogResult.APPROVE) { 422 + File selectedDir = dirChooser.getSelectedFile(); 423 + 424 + if (!ProjectValidator.isValidProject(selectedDir)) { 425 + SwingUtils.getErrorDialog() 426 + .setTitle("Invalid Project") 427 + .setMessage("The selected directory is not a valid Star Rod project.", 428 + "A valid project must have ver/us/splat.yaml") 429 + .show(); 430 + return; 431 + } 432 + 433 + // Add to history and select 434 + Project newProject = new Project(selectedDir); 435 + projectManager.recordProjectOpened(selectedDir); 436 + 437 + // Refresh and select the new project 438 + refreshProjectList(); 439 + updateListFilter(); 440 + list.setSelectedValue(newProject, true); 441 + 442 + // Open it immediately 443 + openSelectedProject(); 444 + } 445 + } 446 + 447 + private void removeSelectedProject() 448 + { 449 + Project selected = list.getSelectedValue(); 450 + if (selected == null) { 451 + return; 452 + } 453 + 454 + int choice = SwingUtils.getConfirmDialog() 455 + .setTitle("Remove Project") 456 + .setMessage("Remove \"" + selected.getName() + "\" from the project list?", 457 + "The project files will not be deleted.") 458 + .setOptionsType(JOptionPane.YES_NO_OPTION) 459 + .choose(); 460 + 461 + if (choice == JOptionPane.YES_OPTION) { 462 + projectManager.removeFromHistory(selected); 463 + refreshProjectList(); 464 + updateListFilter(); 465 + 466 + if (filteredListModel.getSize() > 0) { 467 + list.setSelectedIndex(0); 468 + } 469 + } 470 + } 471 + 472 + private void deleteSelectedProjectFromDisk() 473 + { 474 + Project selected = list.getSelectedValue(); 475 + if (selected == null) { 476 + return; 477 + } 478 + 479 + // Show confirmation dialog with checkbox 480 + JCheckBox confirmCheck = new JCheckBox("I understand this cannot be undone"); 481 + confirmCheck.setSelected(false); 482 + 483 + Object[] message = { 484 + "WARNING: This will permanently delete all files in:", 485 + selected.getPath().getAbsolutePath(), 486 + "", 487 + confirmCheck 488 + }; 489 + 490 + int choice = JOptionPane.showConfirmDialog( 491 + this, 492 + message, 493 + "Delete Project from Disk", 494 + JOptionPane.OK_CANCEL_OPTION, 495 + JOptionPane.WARNING_MESSAGE 496 + ); 497 + 498 + if (choice == JOptionPane.OK_OPTION && confirmCheck.isSelected()) { 499 + boolean success = projectManager.deleteFromDisk(selected); 500 + if (!success) { 501 + SwingUtils.getErrorDialog() 502 + .setTitle("Delete Failed") 503 + .setMessage("Failed to delete project files.", 504 + "The project has been removed from the list.", 505 + "You may need to delete the files manually.") 506 + .show(); 507 + } 508 + 509 + refreshProjectList(); 510 + updateListFilter(); 511 + 512 + if (filteredListModel.getSize() > 0) { 513 + list.setSelectedIndex(0); 514 + } 515 + } 516 + else if (choice == JOptionPane.OK_OPTION && !confirmCheck.isSelected()) { 517 + SwingUtils.getWarningDialog() 518 + .setTitle("Delete Cancelled") 519 + .setMessage("You must check the confirmation box to delete.") 520 + .show(); 521 + } 522 + } 523 + }
src/main/resources/splash/launcher.jpg

This is a binary file and will not be displayed.