diff --git a/pom.xml b/pom.xml index 9cbb927..53264db 100644 --- a/pom.xml +++ b/pom.xml @@ -67,7 +67,7 @@ info.picocli picocli - 4.7.7 + 2.2.0 diff --git a/src/main/java/net/vhati/modmanager/FTLModManager.java b/src/main/java/net/vhati/modmanager/FTLModManager.java index edc78a1..c431262 100644 --- a/src/main/java/net/vhati/modmanager/FTLModManager.java +++ b/src/main/java/net/vhati/modmanager/FTLModManager.java @@ -1,8 +1,14 @@ package net.vhati.modmanager; import java.io.File; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Date; +import javax.swing.JFileChooser; +import javax.swing.JOptionPane; +import javax.swing.LookAndFeel; +import javax.swing.SwingUtilities; +import javax.swing.UIManager; import ch.qos.logback.classic.LoggerContext; import ch.qos.logback.classic.spi.ILoggingEvent; @@ -14,16 +20,22 @@ import org.slf4j.bridge.SLF4JBridgeHandler; import net.vhati.modmanager.cli.SlipstreamCLI; import net.vhati.modmanager.core.ComparableVersion; +import net.vhati.modmanager.core.FTLUtilities; +import net.vhati.modmanager.core.SlipstreamConfig; +import net.vhati.modmanager.ui.ManagerFrame; public class FTLModManager { - private static final Logger log = LoggerFactory.getLogger(FTLModManager.class); + private static final Logger log = LoggerFactory.getLogger( FTLModManager.class ); public static final String APP_NAME = "Slipstream Mod Manager"; - public static final ComparableVersion APP_VERSION = new ComparableVersion("1.9.1"); + public static final ComparableVersion APP_VERSION = new ComparableVersion( "1.9.1" ); + public static final String APP_URL = "TODO"; + public static final String APP_AUTHOR = "jan-leila"; - public static void main(String[] args) { + + public static void main( String[] args ) { // Redirect any libraries' java.util.Logging messages. SLF4JBridgeHandler.removeHandlersForRootLogger(); SLF4JBridgeHandler.install(); @@ -35,29 +47,299 @@ public class FTLModManager { LoggerContext lc = (LoggerContext)LoggerFactory.getILoggerFactory(); PatternLayoutEncoder encoder = new PatternLayoutEncoder(); - encoder.setContext(lc); + encoder.setContext( lc ); encoder.setCharset(StandardCharsets.UTF_8); - encoder.setPattern("%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"); + encoder.setPattern( "%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" ); encoder.start(); - FileAppender fileAppender = new FileAppender<>(); - fileAppender.setContext(lc); - fileAppender.setName("LogFile"); - fileAppender.setFile(new File("./modman-log.txt").getAbsolutePath()); - fileAppender.setAppend(false); - fileAppender.setEncoder(encoder); + FileAppender fileAppender = new FileAppender(); + fileAppender.setContext( lc ); + fileAppender.setName( "LogFile" ); + fileAppender.setFile( new File( "./modman-log.txt" ).getAbsolutePath() ); + fileAppender.setAppend( false ); + fileAppender.setEncoder( encoder ); fileAppender.start(); - lc.getLogger(Logger.ROOT_LOGGER_NAME).addAppender(fileAppender); + lc.getLogger( Logger.ROOT_LOGGER_NAME ).addAppender( fileAppender ); // Log a welcome message. - log.debug("Started: {}", new Date()); - log.debug("{} v{}", APP_NAME, APP_VERSION); - log.debug("OS: {} {}", System.getProperty("os.name"), System.getProperty("os.version")); - log.debug("VM: {}, {}, {}", System.getProperty("java.vm.name"), System.getProperty("java.version"), System.getProperty("os.arch")); + log.debug( "Started: {}", new Date() ); + log.debug( "{} v{}", APP_NAME, APP_VERSION ); + log.debug( "OS: {} {}", System.getProperty( "os.name" ), System.getProperty( "os.version" ) ); + log.debug( "VM: {}, {}, {}", System.getProperty( "java.vm.name" ), System.getProperty( "java.version" ), System.getProperty( "os.arch" ) ); - Thread.setDefaultUncaughtExceptionHandler((t, e) -> log.error("Uncaught exception in thread: {}", t.toString(), e)); + Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() { + @Override + public void uncaughtException( Thread t, Throwable e ) { + log.error("Uncaught exception in thread: {}", t.toString(), e); + } + }); - SlipstreamCLI.main(args); + if ( args.length > 0 ) { + SlipstreamCLI.main( args ); + return; + } + + // Ensure all popups are triggered from the event dispatch thread. + SwingUtilities.invokeLater(FTLModManager::guiInit); + } + + + private static void guiInit() { + try { + // TODO: get mods file from env var + // Nag if the jar was double-clicked. + if (!new File("./mods/").exists()) { + String currentPath = new File( "." ).getAbsoluteFile().getParentFile().getAbsolutePath(); + + log.error( String.format( "Slipstream could not find its own folder (Currently in \"%s\"), exiting...", currentPath ) ); + showErrorDialog( String.format( "Slipstream could not find its own folder.\nCurrently in: %s\n\nRun one of the following instead of the jar...\nWindows: modman.exe or modman_admin.exe\nLinux/OSX: modman.command or modman-cli.sh\n\nSlipstream will now exit.", currentPath ) ); + + throw new ExitException(); + } + + // TODO: get config file from env var + File configFile = new File( "modman.cfg" ); + + SlipstreamConfig appConfig = new SlipstreamConfig(configFile); + + // Look-and-Feel. + boolean useDefaultUI = Boolean.parseBoolean(appConfig.getProperty(SlipstreamConfig.USE_DEFAULT_UI, "false")); + + if ( !useDefaultUI ) { + LookAndFeel defaultLaf = UIManager.getLookAndFeel(); + log.debug( "Default look and feel is: "+ defaultLaf.getName() ); + + try { + log.debug( "Setting system look and feel: "+ UIManager.getSystemLookAndFeelClassName() ); + + // SystemLaf is risky. It may throw an exception, or lead to graphical bugs. + // Problems are generally caused by custom Windows themes. + UIManager.setLookAndFeel( UIManager.getSystemLookAndFeelClassName() ); + } + catch ( Exception e ) { + log.error( "Failed to set system look and feel", e ); + log.info( "Setting "+ SlipstreamConfig.USE_DEFAULT_UI +"=true in the config file to prevent this error..." ); + + appConfig.setProperty( SlipstreamConfig.USE_DEFAULT_UI, "true" ); + + try { + UIManager.setLookAndFeel( defaultLaf ); + } + catch ( Exception f ) { + log.error( "Error returning to the default look and feel after failing to set system look and feel", f ); + + // Write an emergency config and exit. + try { + appConfig.writeConfig(); + } + catch ( IOException g ) { + log.error( String.format( "Error writing config to \"%s\"", configFile.getPath(), g ) ); + } + + throw new ExitException(); + } + } + } + else { + log.debug( "Using default Look and Feel" ); + } + + // FTL Resources Path. + File datsDir = null; + String datsPath = appConfig.getProperty( SlipstreamConfig.FTL_DATS_PATH, "" ); + + if ( datsPath.length() > 0 ) { + log.info( "Using FTL dats path from config: "+ datsPath ); + datsDir = new File( datsPath ); + if ( FTLUtilities.isDatsDirValid( datsDir ) == false ) { + log.error( "The config's "+ SlipstreamConfig.FTL_DATS_PATH +" does not exist, or it is invalid" ); + datsDir = null; + } + } + else { + log.debug( "No "+ SlipstreamConfig.FTL_DATS_PATH +" previously set" ); + } + + // Find/prompt for the path to set in the config. + if ( datsDir == null ) { + datsDir = FTLUtilities.findDatsDir(); + if ( datsDir != null ) { + int response = JOptionPane.showConfirmDialog( null, "FTL resources were found in:\n"+ datsDir.getPath() +"\nIs this correct?", "Confirm", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE ); + if ( response == JOptionPane.NO_OPTION ) datsDir = null; + } + + if ( datsDir == null ) { + log.debug( "FTL dats path was not located automatically. Prompting user for location" ); + datsDir = FTLUtilities.promptForDatsDir( null ); + } + + if ( datsDir != null ) { + appConfig.setProperty( SlipstreamConfig.FTL_DATS_PATH, datsDir.getAbsolutePath() ); + log.info( "FTL dats located at: "+ datsDir.getAbsolutePath() ); + } + } + + if ( datsDir == null ) { + showErrorDialog( "FTL resources were not found.\nSlipstream will now exit." ); + log.debug( "No FTL dats path found, exiting" ); + + throw new ExitException(); + } + + // Ask about Steam. + if ( appConfig.getProperty( SlipstreamConfig.STEAM_DISTRO, "" ).length() == 0 ) { + int steamBasedResponse = JOptionPane.showConfirmDialog( null, "Was FTL installed via Steam?", "Confirm", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE ); + if ( steamBasedResponse == JOptionPane.YES_OPTION ) { + appConfig.setProperty( SlipstreamConfig.STEAM_DISTRO, "true" ); + } + else { + appConfig.setProperty( SlipstreamConfig.STEAM_DISTRO, "false" ); + } + } + + // If this is a Steam distro. + if ( "true".equals( appConfig.getProperty( SlipstreamConfig.STEAM_DISTRO, "false" ) ) ) { + + // Find Steam's executable. + if ( appConfig.getProperty( SlipstreamConfig.STEAM_EXE_PATH, "" ).length() == 0 ) { + + File steamExeFile = FTLUtilities.findSteamExe(); + + if ( steamExeFile == null && System.getProperty( "os.name" ).startsWith( "Windows" ) ) { + try { + String registryExePath = FTLUtilities.queryRegistryKey( "HKCU\\Software\\Valve\\Steam", "SteamExe", "REG_SZ" ); + if ( registryExePath != null && !(steamExeFile=new File( registryExePath )).exists() ) { + steamExeFile = null; + } + } + catch( IOException e ) { + log.error( "Error while querying registry for Steam's path", e ); + } + } + + if ( steamExeFile != null ) { + int response = JOptionPane.showConfirmDialog( null, "Steam was found at:\n"+ steamExeFile.getPath() +"\nIs this correct?", "Confirm", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE ); + if ( response == JOptionPane.NO_OPTION ) steamExeFile = null; + } + + if ( steamExeFile == null ) { + log.debug( "Steam was not located automatically. Prompting user for location" ); + + String steamPrompt = "" + + "You will be prompted to locate Steam's executable.\n" + + "- Windows: Steam.exe\n" + + "- Linux: steam\n" + + "- OSX: Steam.app\n" + + "\n" + + "If you can't find it, you can cancel and set it later."; + JOptionPane.showMessageDialog( null, steamPrompt, "Find Steam", JOptionPane.INFORMATION_MESSAGE ); + + JFileChooser steamExeChooser = new JFileChooser(); + steamExeChooser.setDialogTitle( "Find Steam.exe or steam or Steam.app" ); + steamExeChooser.setFileHidingEnabled( false ); + steamExeChooser.setMultiSelectionEnabled( false ); + + if ( steamExeChooser.showOpenDialog( null ) == JFileChooser.APPROVE_OPTION ) { + steamExeFile = steamExeChooser.getSelectedFile(); + if ( !steamExeFile.exists() ) steamExeFile = null; + } + } + + if ( steamExeFile != null ) { + appConfig.setProperty( SlipstreamConfig.STEAM_EXE_PATH, steamExeFile.getAbsolutePath() ); + log.info( "Steam located at: "+ steamExeFile.getAbsolutePath() ); + } + } + + if ( appConfig.getProperty( SlipstreamConfig.STEAM_EXE_PATH, "" ).length() > 0 ) { + + if ( appConfig.getProperty( SlipstreamConfig.RUN_STEAM_FTL, "" ).length() == 0 ) { + + String[] launchOptions = new String[] {"Directly", "Steam"}; + int launchResponse = JOptionPane.showOptionDialog( null, "Would you prefer to launch FTL directly, or via Steam?", "How to Launch?", JOptionPane.DEFAULT_OPTION, JOptionPane.PLAIN_MESSAGE, null, launchOptions, launchOptions[1] ); + if ( launchResponse == 0 ) { + appConfig.setProperty( SlipstreamConfig.RUN_STEAM_FTL, "false" ); + } + else if ( launchResponse == 1 ) { + appConfig.setProperty( SlipstreamConfig.RUN_STEAM_FTL, "true" ); + } + } + } + } + + // Prompt if update_catalog is invalid or hasn't been set. + boolean askAboutUpdates = false; + if ( !appConfig.getProperty( SlipstreamConfig.UPDATE_CATALOG, "" ).matches( "^\\d+$" ) ) + askAboutUpdates = true; + if ( !appConfig.getProperty( SlipstreamConfig.UPDATE_APP, "" ).matches( "^\\d+$" ) ) + askAboutUpdates = true; + + if ( askAboutUpdates ) { + String updatePrompt = "" + + "Would you like Slipstream to periodically check for updates?\n" + + "\n" + + "You can change this later."; + + int response = JOptionPane.showConfirmDialog( null, updatePrompt, "Updates", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE ); + if ( response == JOptionPane.YES_OPTION ) { + appConfig.setProperty( SlipstreamConfig.UPDATE_CATALOG, "7" ); + appConfig.setProperty( SlipstreamConfig.UPDATE_APP, "4" ); + } + else { + appConfig.setProperty( SlipstreamConfig.UPDATE_CATALOG, "0" ); + appConfig.setProperty( SlipstreamConfig.UPDATE_APP, "0" ); + } + } + + ManagerFrame frame = null; + try { + frame = new ManagerFrame( appConfig, APP_NAME, APP_VERSION, APP_URL, APP_AUTHOR ); + frame.init(); + frame.setVisible( true ); + } + catch ( Exception e ) { + log.error( "Failed to create and init the main window", e ); + + // If the frame is constructed, but an exception prevents it + // becoming visible, that *must* be caught. The frame registers + // itself as a global uncaught exception handler. It doesn't + // dispose() itself in the handler, so EDT will wait forever + // for an invisible window to close. + + if ( frame != null && frame.isDisplayable() ) { + frame.setDisposeNormally( false ); + frame.dispose(); + } + + throw new ExitException(); + } + } + catch ( ExitException e ) { + System.gc(); + // System.exit( 1 ); // Don't do this (InterruptedException). Let EDT end gracefully. + return; + } + } + + private static void showErrorDialog( String message ) { + JOptionPane.showMessageDialog( null, message, "Error", JOptionPane.ERROR_MESSAGE ); + } + + private static class ExitException extends RuntimeException { + public ExitException() { + } + + public ExitException( String message ) { + super( message ); + } + + public ExitException( Throwable cause ) { + super( cause ); + } + + public ExitException( String message, Throwable cause ) { + super( message, cause ); + } } } diff --git a/src/main/java/net/vhati/modmanager/cli/SlipstreamCLI.java b/src/main/java/net/vhati/modmanager/cli/SlipstreamCLI.java index 330051d..a10f407 100644 --- a/src/main/java/net/vhati/modmanager/cli/SlipstreamCLI.java +++ b/src/main/java/net/vhati/modmanager/cli/SlipstreamCLI.java @@ -7,13 +7,21 @@ import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.InputStream; +import java.io.InputStreamReader; import java.io.IOException; -import java.util.*; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Properties; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; import picocli.CommandLine; -import picocli.CommandLine.*; +import picocli.CommandLine.Command; +import picocli.CommandLine.IVersionProvider; +import picocli.CommandLine.Option; +import picocli.CommandLine.ParameterException; +import picocli.CommandLine.Parameters; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,633 +41,443 @@ import net.vhati.modmanager.core.Report.ReportFormatter; import net.vhati.modmanager.core.Report.ReportMessage; import net.vhati.modmanager.core.SlipstreamConfig; + public class SlipstreamCLI { - private static final Logger log = LoggerFactory.getLogger(SlipstreamCLI.class); + private static final Logger log = LoggerFactory.getLogger( SlipstreamCLI.class ); - private static final File backupDir = new File("./backup/"); - private static final File modsDir = new File("./mods/"); + private static File backupDir = new File( "./backup/" ); + private static File modsDir = new File( "./mods/" ); private static Thread.UncaughtExceptionHandler exceptionHandler = null; - public static void main(String[] args) { + public static void main( String[] args ) { - exceptionHandler = (t, e) -> { - log.error("Uncaught exception in thread: {}", t.toString(), e); - System.exit(1); - }; + exceptionHandler = new Thread.UncaughtExceptionHandler() { + @Override + public void uncaughtException( Thread t, Throwable e ) { + log.error( "Uncaught exception in thread: "+ t.toString(), e ); + System.exit( 1 ); + } + }; - CommandLine commandLine = new CommandLine(new BaseCommand()) - .addSubcommand("start", new StartCommand()) - .addSubcommand("patch", new PatchCommand()) - .addSubcommand("list", new ListCommand()) - .addSubcommand("clean", new CleanCommand()); + SlipstreamCommand slipstreamCmd = new SlipstreamCommand(); + CommandLine commandLine = new CommandLine( slipstreamCmd ); + try { + commandLine.parse( args ); + } + catch ( ParameterException e ) { + //For multiple subcommands, e.getCommandLine() returns the one that failed. - commandLine.execute(args); + System.err.println( "Error parsing commandline: "+ e.getMessage() ); + System.exit( 1 ); + } -// try { -// commandLine.parse(args); -// } -// catch (ParameterException e) { -// //For multiple subcommands, e.getCommandLine() returns the one that failed. -// -// System.err.println("Error parsing commandline: "+ e.getMessage()); -// System.exit(1); -// } -// -// ActionFlow actionFlow = getCommandAction(commandLine); -// -// switch (actionFlow) { -// case VERSION -> { -// commandLine.usage(System.out); -// System.exit(0); -// } -// case HELP -> { -// commandLine.printVersionHelp(System.out); -// System.exit(0); -// } -// } + if ( commandLine.isUsageHelpRequested() ) { + commandLine.usage( System.out ); + System.exit( 0 ); + } + if ( commandLine.isVersionHelpRequested() ) { + commandLine.printVersionHelp( System.out ); + System.exit( 0 ); + } -// if (slipstreamCmd.validate) { -// boolean success = validate(slipstreamCmd.modFileNames); -// System.exit(success ? 0 : 1); -// } -// -// File configFile = new File("modman.cfg"); -// SlipstreamConfig appConfig = new SlipstreamConfig(configFile); -// -// if (slipstreamCmd.listMods) { -// listMods(appConfig); -// System.exit(0); -// } -// -// File datsDir = null; -// if (slipstreamCmd.extractDatsDir != null || -// slipstreamCmd.patch || -// slipstreamCmd.runftl) { -// datsDir = getDatsDir(appConfig); -// } -// -// if (slipstreamCmd.extractDatsDir != null) { -// extractDatsDir(slipstreamCmd, datsDir); -// System.exit(0); -// } -// -// if (slipstreamCmd.patch) { -// boolean success = patch(slipstreamCmd, datsDir); -// if (!success) { -// System.exit(1); -// } -// } -// -// if (slipstreamCmd.runftl) { -// boolean success = runFtl(appConfig, datsDir); -// System.exit(success ? 0: 1); -// } -// -// System.exit(0); + if ( slipstreamCmd.validate ) { + boolean success = validate(slipstreamCmd.modFileNames); + System.exit( success ? 0 : 1); + } + + File configFile = new File( "modman.cfg" ); + SlipstreamConfig appConfig = new SlipstreamConfig( configFile ); + + if ( slipstreamCmd.listMods ) { + listMods(appConfig); + System.exit( 0 ); + } + + File datsDir = null; + if ( slipstreamCmd.extractDatsDir != null || + slipstreamCmd.patch || + slipstreamCmd.runftl ) { + datsDir = getDatsDir( appConfig ); + } + + if ( slipstreamCmd.extractDatsDir != null ) { + extractDatsDir(slipstreamCmd, datsDir); + System.exit( 0 ); + } + + if ( slipstreamCmd.patch ) { + boolean success = patch(slipstreamCmd, datsDir); + if (!success) { + System.exit( 1 ); + } + } + + if ( slipstreamCmd.runftl ) { + boolean success = runFtl(appConfig, datsDir); + System.exit(success ? 0 : 1 ); + } + + System.exit( 0 ); } -// private static ActionFlow getCommandAction(CommandLine commandLine) { -// if (commandLine.isUsageHelpRequested()) { -// return ActionFlow.HELP; -// } -// if (commandLine.isVersionHelpRequested()) { -// return ActionFlow.VERSION; -// } -// -// return null; -// } + private static void extractDatsDir(SlipstreamCommand slipstreamCmd, File datsDir) { + log.info( "Extracting dats..." ); -// private static void extractDatsDir(RunCommand slipstreamCmd, File datsDir) { -// log.info("Extracting dats..."); -// -// File extractDir = slipstreamCmd.extractDatsDir; -// -// FolderPack dstPack = null; -// List srcPacks = new ArrayList(2); -// InputStream is = null; -// try { -// File ftlDatFile = new File(datsDir, "ftl.dat"); -// File dataDatFile = new File(datsDir, "data.dat"); -// File resourceDatFile = new File(datsDir, "resource.dat"); -// -// if (ftlDatFile.exists()) { // FTL 1.6.1. -// AbstractPack ftlPack = new PkgPack(ftlDatFile, "r"); -// srcPacks.add(ftlPack); -// } -// else if (dataDatFile.exists() && resourceDatFile.exists()) { // FTL 1.01-1.5.13. -// AbstractPack dataPack = new FTLPack(dataDatFile, "r"); -// AbstractPack resourcePack = new FTLPack(resourceDatFile, "r"); -// srcPacks.add(dataPack); -// srcPacks.add(resourcePack); -// } -// else { -// throw new FileNotFoundException(String.format("Could not find either \"%s\" or both \"%s\" and \"%s\"", ftlDatFile.getName(), dataDatFile.getName(), resourceDatFile.getName())); -// } -// -// if (!extractDir.exists()) { -// boolean success = extractDir.mkdirs(); -// if (success) { -// log.error("Error extracting dats"); -// System.exit(1); -// } -// }; -// -// dstPack = new FolderPack(extractDir); -// -// for (AbstractPack srcPack : srcPacks) { -// List innerPaths = srcPack.list(); -// -// for (String innerPath : innerPaths) { -// if (dstPack.contains(innerPath)) { -// log.info("While extracting resources, this file was overwritten: "+ innerPath); -// dstPack.remove(innerPath); -// } -// is = srcPack.getInputStream(innerPath); -// dstPack.add(innerPath, is); -// } -// srcPack.close(); -// } -// } -// catch (IOException e) { -// log.error("Error extracting dats", e); -// System.exit(1); -// } -// finally { -// try {if (is != null) is.close();} -// catch (IOException ignored) {} -// -// try {if (dstPack != null) dstPack.close();} -// catch (IOException ignored) {} -// -// for (AbstractPack pack : srcPacks) { -// try {pack.close();} -// catch (IOException ignored) {} -// } -// } -// } -// -// private static boolean patch(RunCommand slipstreamCmd, File datsDir) { -// log.info("Patching..."); -// -// DelayedDeleteHook deleteHook = new DelayedDeleteHook(); -// Runtime.getRuntime().addShutdownHook(deleteHook); -// -// List modFiles = new ArrayList(); -// if (slipstreamCmd.modFileNames != null) { -// for (String modFileName : slipstreamCmd.modFileNames) { -// File modFile = new File(modsDir, modFileName); -// -// if (modFile.isDirectory()) { -// log.info("Zipping dir: {}/", modFile.getName()); -// try { -// modFile = createTempMod(modFile); -// deleteHook.addDoomedFile(modFile); -// } -// catch (IOException e) { -// log.error("Error zipping dir: {}/", modFile.getName(), e); -// return false; -// } -// } -// -// modFiles.add(modFile); -// } -// } -// -// boolean globalPanic = slipstreamCmd.globalPanic; -// -// SilentPatchObserver patchObserver = new SilentPatchObserver(); -// ModPatchThread patchThread = new ModPatchThread(modFiles, datsDir, backupDir, globalPanic, patchObserver); -// Thread.setDefaultUncaughtExceptionHandler(exceptionHandler); -// deleteHook.addWatchedThread(patchThread); -// -// patchThread.start(); -// while (patchThread.isAlive()) { -// try {patchThread.join();} -// catch (InterruptedException ignored) {} -// } -// -// return patchObserver.hasSucceeded(); -// } + File extractDir = slipstreamCmd.extractDatsDir; -// private static boolean validate(String[] modFileNames) { -// DelayedDeleteHook deleteHook = new DelayedDeleteHook(); -// Runtime.getRuntime().addShutdownHook(deleteHook); -// -// log.info("Validating..."); -// -// StringBuilder resultBuf = new StringBuilder(); -// ReportFormatter formatter = new ReportFormatter(); -// boolean anyInvalid = false; -// -// for (String modFileName : modFileNames) { -// File modFile = new File(modsDir, modFileName); -// -// if (modFile.isDirectory()) { -// log.info("Zipping dir: {}/", modFile.getName()); -// try { -// modFile = createTempMod(modFile); -// deleteHook.addDoomedFile(modFile); -// } -// catch (IOException e) { -// log.error("Error zipping dir: {}/", modFile.getName(), e); -// -// List tmpMessages = new ArrayList(); -// tmpMessages.add(new ReportMessage(ReportMessage.SECTION, modFileName)); -// tmpMessages.add(new ReportMessage(ReportMessage.EXCEPTION, e.getMessage())); -// -// formatter.format(tmpMessages, resultBuf, 0); -// resultBuf.append("\n"); -// -// anyInvalid = true; -// continue; -// } -// } -// -// Report validateReport = ModUtilities.validateModFile(modFile); -// -// formatter.format(validateReport.messages, resultBuf, 0); -// resultBuf.append("\n"); -// -// if (!validateReport.outcome) anyInvalid = true; -// } -// if (resultBuf.isEmpty()) { -// resultBuf.append("No mods were checked."); -// } -// -// System.out.println(); -// System.out.println(resultBuf); -// return !anyInvalid; -// } -// -// private static void listMods(SlipstreamConfig appConfig) { -// log.info("Listing mods..."); -// -// boolean allowZip = appConfig.getProperty(SlipstreamConfig.ALLOW_ZIP, "false").equals("true"); -// File[] modFiles = modsDir.listFiles(new ModAndDirFileFilter(allowZip, true)); -// List dirList = new ArrayList<>(); -// List fileList = new ArrayList<>(); -// assert modFiles != null; -// for (File f : modFiles) { -// if (f.isDirectory()) -// dirList.add(f.getName() +"/"); -// else -// fileList.add(f.getName()); -// } -// Collections.sort(dirList); -// Collections.sort(fileList); -// for (String s : dirList) System.out.println(s); -// for (String s : fileList) System.out.println(s); -// } -// -// private static boolean runFtl(SlipstreamConfig appConfig, File datsDir) { -// log.info("Running FTL..."); -// -// File exeFile = null; -// String[] exeArgs = null; -// -// // Try to run via Steam. -// if ("true".equals(appConfig.getProperty(SlipstreamConfig.RUN_STEAM_FTL, "false"))) { -// -// String steamPath = appConfig.getProperty(SlipstreamConfig.STEAM_EXE_PATH); -// if (!steamPath.isEmpty()) { -// exeFile = new File(steamPath); -// -// if (exeFile.exists()) { -// exeArgs = new String[] {"-applaunch", FTLUtilities.STEAM_APPID_FTL}; -// } -// else { -// log.warn(String.format("%s does not exist: %s", SlipstreamConfig.STEAM_EXE_PATH, exeFile.getAbsolutePath())); -// exeFile = null; -// } -// } -// -// if (exeFile == null) { -// log.warn("Steam executable could not be found, so FTL will be launched directly"); -// } -// -// } -// // Try to run directly. -// if (exeFile == null) { -// exeFile = FTLUtilities.findGameExe(datsDir); -// -// if (exeFile != null) { -// exeArgs = new String[0]; -// } else { -// log.warn("FTL executable could not be found"); -// } -// } -// -// if (exeFile != null) { -// try { -// FTLUtilities.launchExe(exeFile, exeArgs); -// } -// catch (Exception e) { -// log.error("Error launching FTL", e); -// return false; -// } -// } -// else { -// log.error("No executables were found to launch FTL"); -// return false; -// } -// -// return true; -// } + FolderPack dstPack = null; + List srcPacks = new ArrayList( 2 ); + InputStream is = null; + try { + File ftlDatFile = new File( datsDir, "ftl.dat" ); + File dataDatFile = new File( datsDir, "data.dat" ); + File resourceDatFile = new File( datsDir, "resource.dat" ); -// /** -// * Checks the validity of the config's dats path and returns it. -// * Or exits if the path is invalid. -// */ -// private static File getDatsDir(SlipstreamConfig appConfig) { -// File datsDir = null; -// String datsPath = appConfig.getProperty(SlipstreamConfig.FTL_DATS_PATH, ""); -// -// if (!datsPath.isEmpty()) { -// log.info("Using FTL dats path from config: "+ datsPath); -// datsDir = new File(datsPath); -// if (!FTLUtilities.isDatsDirValid(datsDir)) { -// log.error("The config's "+ SlipstreamConfig.FTL_DATS_PATH +" does not exist, or it is invalid"); -// datsDir = null; -// } -// } -// else { -// log.error("No FTL dats path previously set"); -// } -// if (datsDir == null) { -// log.error("Run the GUI once, or edit the config file, and try again"); -// System.exit(1); -// } -// -// return datsDir; -// } + if ( ftlDatFile.exists() ) { // FTL 1.6.1. + AbstractPack ftlPack = new PkgPack( ftlDatFile, "r" ); + srcPacks.add( ftlPack ); + } + else if ( dataDatFile.exists() && resourceDatFile.exists() ) { // FTL 1.01-1.5.13. + AbstractPack dataPack = new FTLPack( dataDatFile, "r" ); + AbstractPack resourcePack = new FTLPack( resourceDatFile, "r" ); + srcPacks.add( dataPack ); + srcPacks.add( resourcePack ); + } + else { + throw new FileNotFoundException( String.format( "Could not find either \"%s\" or both \"%s\" and \"%s\"", ftlDatFile.getName(), dataDatFile.getName(), resourceDatFile.getName() ) ); + } + + if ( !extractDir.exists() ) extractDir.mkdirs(); + + dstPack = new FolderPack( extractDir ); + + for ( AbstractPack srcPack : srcPacks ) { + List innerPaths = srcPack.list(); + + for ( String innerPath : innerPaths ) { + if ( dstPack.contains( innerPath ) ) { + log.info( "While extracting resources, this file was overwritten: "+ innerPath ); + dstPack.remove( innerPath ); + } + is = srcPack.getInputStream( innerPath ); + dstPack.add( innerPath, is ); + } + srcPack.close(); + } + } + catch ( IOException e ) { + log.error( "Error extracting dats", e ); + System.exit( 1 ); + } + finally { + try {if ( is != null ) is.close();} + catch ( IOException ex ) {} + + try {if ( dstPack != null ) dstPack.close();} + catch ( IOException ex ) {} + + for ( AbstractPack pack : srcPacks ) { + try {pack.close();} + catch ( IOException ex ) {} + } + } + } + + private static boolean patch(SlipstreamCommand slipstreamCmd, File datsDir) { + log.info( "Patching..." ); + + DelayedDeleteHook deleteHook = new DelayedDeleteHook(); + Runtime.getRuntime().addShutdownHook( deleteHook ); + + List modFiles = new ArrayList(); + if ( slipstreamCmd.modFileNames != null ) { + for ( String modFileName : slipstreamCmd.modFileNames ) { + File modFile = new File( modsDir, modFileName ); + + if ( modFile.isDirectory() ) { + log.info( String.format( "Zipping dir: %s/", modFile.getName() ) ); + try { + modFile = createTempMod( modFile ); + deleteHook.addDoomedFile( modFile ); + } + catch ( IOException e ) { + log.error( String.format( "Error zipping dir: %s/", modFile.getName() ), e ); + return false; + } + } + + modFiles.add( modFile ); + } + } + + boolean globalPanic = slipstreamCmd.globalPanic; + + SilentPatchObserver patchObserver = new SilentPatchObserver(); + ModPatchThread patchThread = new ModPatchThread( modFiles, datsDir, backupDir, globalPanic, patchObserver ); + patchThread.setDefaultUncaughtExceptionHandler( exceptionHandler ); + deleteHook.addWatchedThread( patchThread ); + + patchThread.start(); + while ( patchThread.isAlive() ) { + try {patchThread.join();} + catch ( InterruptedException e ) {} + } + + if ( !patchObserver.hasSucceeded() ) return false; + return true; + } + + private static boolean validate(String[] modFileNames) { + DelayedDeleteHook deleteHook = new DelayedDeleteHook(); + Runtime.getRuntime().addShutdownHook( deleteHook ); + + log.info( "Validating..." ); + + StringBuilder resultBuf = new StringBuilder(); + ReportFormatter formatter = new ReportFormatter(); + boolean anyInvalid = false; + + for ( String modFileName : modFileNames ) { + File modFile = new File( modsDir, modFileName ); + + if ( modFile.isDirectory() ) { + log.info( String.format( "Zipping dir: %s/", modFile.getName() ) ); + try { + modFile = createTempMod( modFile ); + deleteHook.addDoomedFile( modFile ); + } + catch ( IOException e ) { + log.error( String.format( "Error zipping dir: %s/", modFile.getName() ), e ); + + List tmpMessages = new ArrayList(); + tmpMessages.add( new ReportMessage( ReportMessage.SECTION, modFileName ) ); + tmpMessages.add( new ReportMessage( ReportMessage.EXCEPTION, e.getMessage() ) ); + + formatter.format( tmpMessages, resultBuf, 0 ); + resultBuf.append( "\n" ); + + anyInvalid = true; + continue; + } + } + + Report validateReport = ModUtilities.validateModFile( modFile ); + + formatter.format( validateReport.messages, resultBuf, 0 ); + resultBuf.append( "\n" ); + + if ( validateReport.outcome == false ) anyInvalid = true; + } + if ( resultBuf.length() == 0 ) { + resultBuf.append( "No mods were checked." ); + } + + System.out.println(); + System.out.println(resultBuf); + return !anyInvalid; + } + + private static void listMods(SlipstreamConfig appConfig) { + log.info( "Listing mods..." ); + + boolean allowZip = appConfig.getProperty( SlipstreamConfig.ALLOW_ZIP, "false" ).equals( "true" ); + File[] modFiles = modsDir.listFiles( new ModAndDirFileFilter( allowZip, true ) ); + List dirList = new ArrayList(); + List fileList = new ArrayList(); + for ( File f : modFiles ) { + if ( f.isDirectory() ) + dirList.add( f.getName() +"/" ); + else + fileList.add( f.getName() ); + } + Collections.sort( dirList ); + Collections.sort( fileList ); + for ( String s : dirList ) System.out.println( s ); + for ( String s : fileList ) System.out.println( s ); + } + + private static boolean runFtl(SlipstreamConfig appConfig, File datsDir) { + log.info( "Running FTL..." ); + + File exeFile = null; + String[] exeArgs = null; + + // Try to run via Steam. + if ( "true".equals( appConfig.getProperty( SlipstreamConfig.RUN_STEAM_FTL, "false" ) ) ) { + + String steamPath = appConfig.getProperty( SlipstreamConfig.STEAM_EXE_PATH ); + if ( steamPath.length() > 0 ) { + exeFile = new File( steamPath ); + + if ( exeFile.exists() ) { + exeArgs = new String[] {"-applaunch", FTLUtilities.STEAM_APPID_FTL}; + } + else { + log.warn( String.format( "%s does not exist: %s", SlipstreamConfig.STEAM_EXE_PATH, exeFile.getAbsolutePath() ) ); + exeFile = null; + } + } + + if ( exeFile == null ) { + log.warn( "Steam executable could not be found, so FTL will be launched directly" ); + } + + } + // Try to run directly. + if ( exeFile == null ) { + exeFile = FTLUtilities.findGameExe( datsDir ); + + if ( exeFile != null ) { + exeArgs = new String[0]; + } else { + log.warn( "FTL executable could not be found" ); + } + } + + if ( exeFile != null ) { + try { + FTLUtilities.launchExe( exeFile, exeArgs ); + } + catch ( Exception e ) { + log.error( "Error launching FTL", e ); + return false; + } + } + else { + log.error( "No executables were found to launch FTL" ); + return false; + } + + return true; + } + + /** + * Checks the validity of the config's dats path and returns it. + * Or exits if the path is invalid. + */ + private static File getDatsDir( SlipstreamConfig appConfig ) { + File datsDir = null; + String datsPath = appConfig.getProperty( SlipstreamConfig.FTL_DATS_PATH, "" ); + + if ( datsPath.length() > 0 ) { + log.info( "Using FTL dats path from config: "+ datsPath ); + datsDir = new File( datsPath ); + if ( FTLUtilities.isDatsDirValid( datsDir ) == false ) { + log.error( "The config's "+ SlipstreamConfig.FTL_DATS_PATH +" does not exist, or it is invalid" ); + datsDir = null; + } + } + else { + log.error( "No FTL dats path previously set" ); + } + if ( datsDir == null ) { + log.error( "Run the GUI once, or edit the config file, and try again" ); + System.exit( 1 ); + } + + return datsDir; + } /** * Returns a temporary zip made from a directory. + * * Empty subdirs will be omitted. * The archive will be not be deleted on exit (handle that elsewhere). */ - private static File createTempMod(File dir) throws IOException { - File tempFile = File.createTempFile(dir.getName() +"_temp-", ".zip"); + private static File createTempMod( File dir ) throws IOException { + File tempFile = File.createTempFile( dir.getName() +"_temp-", ".zip" ); - try (FileOutputStream fos = new FileOutputStream(tempFile)) { - ZipOutputStream zos = new ZipOutputStream(new BufferedOutputStream(fos)); - addDirToArchive(zos, dir, null); - zos.close(); - } catch (IOException ignored) { - } + FileOutputStream fos = null; + try { + fos = new FileOutputStream( tempFile ); + ZipOutputStream zos = new ZipOutputStream( new BufferedOutputStream( fos ) ); + addDirToArchive( zos, dir, null ); + zos.close(); + } + finally { + try {if ( fos != null ) fos.close();} + catch ( IOException e ) {} + } return tempFile; } - private static void addDirToArchive(ZipOutputStream zos, File dir, String pathPrefix) throws IOException { - if (pathPrefix == null) pathPrefix = ""; + private static void addDirToArchive( ZipOutputStream zos, File dir, String pathPrefix ) throws IOException { + if ( pathPrefix == null ) pathPrefix = ""; - for (File f : Objects.requireNonNull(dir.listFiles())) { - if (f.isDirectory()) { - addDirToArchive(zos, f, pathPrefix + f.getName() +"/"); + for ( File f : dir.listFiles() ) { + if ( f.isDirectory() ) { + addDirToArchive( zos, f, pathPrefix + f.getName() +"/" ); continue; } - try (FileInputStream is = new FileInputStream(f)) { - zos.putNextEntry(new ZipEntry(pathPrefix + f.getName())); + FileInputStream is = null; + try { + is = new FileInputStream( f ); + zos.putNextEntry( new ZipEntry( pathPrefix + f.getName() ) ); - byte[] buf = new byte[4096]; - int len; - while ((len = is.read(buf)) >= 0) { - zos.write(buf, 0, len); - } - - zos.closeEntry(); - } catch (IOException ignored) { - } - } - } - - @Command( - name = "list", - description = "list all available mods" - ) - public static class ListCommand implements Runnable { - @Override - public void run() { - System.out.println("list command"); - } - } - - @Command( - name = "patch", - description = "create a patched binary using the available mods" - ) - public static class PatchCommand implements Runnable { - @Parameters(index = "0", description = "the location unpatched binary is located at") - File binary; - - @Parameters(description = "list of mods that the binary will be patched with before it starts. (defaults to all mods if non are provided)", mapFallbackValue = Option.NULL_VALUE) - String mods; - - @Option(names = "--dry", description = "skip the patching step but still run the rest of the application") - boolean dry; - - @Override - public void run() { - System.out.println("patch command"); - } - } - - @Command( - name = "clean", - description = "remove all patched binaries" - ) - public static class CleanCommand implements Runnable { - @Override - public void run() { - System.out.println("clean command"); - } - } - - @Command( - name = "install", - description = "install the slipstream mod manager" - ) - public static class InstallCommand implements Runnable { - @Override - public void run() { - System.out.println("install command"); - } - } - - @Command( - name = "uninstall", - description = "uninstall the slipstream mod manager" - ) - public static class UninstallCommand implements Runnable { - @Override - public void run() { - System.out.println("uninstall command"); - } - } - - @Command( - name = "start", - abbreviateSynopsis = true, - sortOptions = false, - description = "Creates and starts a patched version of FTL.", - versionProvider = SlipstreamVersionProvider.class - ) - public static class StartCommand implements Runnable { - @Option(names = "--version", versionHelp = true, description = "output version information and exit") - boolean isVersionCommand; - - @Option(names = { "--game-folder", "--binary", "--data-folder" }, description = "the location of the game files", converter = GameDirectoryConverter.class, mapFallbackValue = Option.NULL_VALUE) - GameDirectory gameDirectory; - - @Parameters(description = "list of mods that the binary will be patched with before it starts. (defaults to all mods if non are provided)", mapFallbackValue = Option.NULL_VALUE) - String[] mods; - - @Option(names = "--dry", description = "skip the patching step but still run the rest of the application") - boolean dry; - - @Override - public void run() { - System.out.println("start command: " + gameDirectory.root.getAbsolutePath()); - } - } - - static class GameDirectoryConverter implements ITypeConverter { - - private GameDirectory getGameDirectory(String fileName) { - if (fileName == null ) { - // TODO: get this from env variables - // TODO: if env variable not set check some common places and prompt the user if they want to use that - return null; - } - return GameDirectory.createGameDirectory(new File(fileName)); - } - - @Override - public GameDirectory convert(String fileName) { - GameDirectory gameDirectory = getGameDirectory(fileName); - if (gameDirectory == null) { - System.out.println("Game not found. Please specify the --game-folder argument, or set the FTL_GAME_FOLDER environment variable."); - System.exit(1); - } - return gameDirectory; - } - } - - static class GameDirectory { - - private static final String GAME_FOLDER_NAME = "FTL Faster Than Light"; - private static final String GAME_DATA_FOLDER_NAME = "data"; - private static final String MODS_FOLDER_NAME = "mods"; - private static final String BACKUP_FOLDER_NAME = "backup"; - private static final String LAUNCH_SCRIPT_FILE_NAME = "FTL"; - private static final String X86_BINARY_FILE_NAME = "FTL.x86"; - - private static boolean isValidGameDirectory(File gameDirectory) { - if ( - !( - gameDirectory.exists() - && gameDirectory.isDirectory() - && gameDirectory.getName().equals(GAME_FOLDER_NAME) - ) - ) { - return false; - } - - File gameDataFolder = new File(gameDirectory, GAME_DATA_FOLDER_NAME); - if ( - !( - gameDataFolder.exists() - && gameDataFolder.isDirectory() - ) - ) { - return false; - } - - File gameLaunchScript = new File(gameDataFolder, LAUNCH_SCRIPT_FILE_NAME); - if ( - !( - gameLaunchScript.exists() - && gameLaunchScript.isFile() - ) - ) { - return false; - } - - // TODO: deal with FTL.amd64 - File gameBinary = new File(gameDataFolder, X86_BINARY_FILE_NAME); - return gameBinary.exists() && gameBinary.isFile(); - } - - static GameDirectory createGameDirectory(File file) { - if (file.isFile()) { - String filename = file.getName(); - if (filename.equals(X86_BINARY_FILE_NAME)) { - File root = file.getParentFile().getParentFile(); - if (isValidGameDirectory(root)) { - return new GameDirectory((root)); - } - } - } - if (file.isDirectory()){ - if (isValidGameDirectory(file)) { - return new GameDirectory(file); + byte[] buf = new byte[4096]; + int len; + while ( (len = is.read(buf)) >= 0 ) { + zos.write( buf, 0, len ); } - String directoryName = file.getName(); - if (directoryName.equals(GAME_DATA_FOLDER_NAME)) { - File root = file.getParentFile(); - if (isValidGameDirectory(root)) { - return new GameDirectory((root)); - } - } + zos.closeEntry(); + } + finally { + try {if ( is != null ) is.close();} + catch ( IOException e ) {} } - return null; - } - - public final File root; - public final File dataDir; - public final File modsDir; - public final File backupDir; - public final File launchScript; - public final File binary; - - GameDirectory(File root) { - this.root = root; - this.dataDir = new File(root, "data"); - this.modsDir = new File(this.dataDir, "mods"); - this.backupDir = new File(this.dataDir, "backup"); - this.launchScript = new File(this.dataDir, "FTL"); - // TODO: this should probably change when we need to use FTL.amd64 instead - this.binary = new File(this.dataDir, X86_BINARY_FILE_NAME); } } - @Command(name = "slipstream") - public static class BaseCommand implements Runnable { + + + @Command( + name = "modman", + abbreviateSynopsis = true, + sortOptions = false, + description = "Perform actions against an FTL installation and/or a list of named mods.", + footer = "%nIf a named mod is a directory, a temporary zip will be created.", + versionProvider = SlipstreamVersionProvider.class + ) + public static class SlipstreamCommand { + @Option(names = "--extract-dats", paramLabel = "DIR", description = "extract FTL resources into a dir") + File extractDatsDir; + + @Option(names = "--global-panic", description = "patch as if advanced find tags had panic='true'") + boolean globalPanic; + + @Option(names = "--list-mods", description = "list available mod names") + boolean listMods; + + @Option(names = "--runftl", description = "run the game (standalone or with 'patch')") + boolean runftl; + + @Option(names = "--patch", description = "revert to vanilla and add named mods (if any)") + boolean patch; + + @Option(names = "--validate", description = "check named mods for problems") + boolean validate; + @Option(names = {"-h", "--help"}, usageHelp = true, description = "display this help and exit") - boolean isHelpCommand; + boolean helpRequested; - @Spec - Model.CommandSpec spec; - - @Override - public void run() { - // if the command was invoked without subcommand, show the usage help - spec.commandLine().usage(System.err); - } + @Option(names = "--version", versionHelp = true, description = "output version information and exit") + boolean versionRequested; + @Parameters(paramLabel = "MODFILE", description = "names of files or directories in the mods/ dir") + String[] modFileNames; } public static class SlipstreamVersionProvider implements IVersionProvider { @Override public String[] getVersion() { return new String[] { - String.format("%s %s", FTLModManager.APP_NAME, FTLModManager.APP_VERSION), + String.format( "%s %s", FTLModManager.APP_NAME, FTLModManager.APP_VERSION ), "Copyright (C) 2013,2014,2017,2018 David Millis", "", "This program is free software; you can redistribute it and/or modify", @@ -685,19 +503,19 @@ public class SlipstreamCLI { private boolean succeeded = false; @Override - public void patchingProgress(final int value, final int max) { + public void patchingProgress( final int value, final int max ) { } @Override - public void patchingStatus(String message) { + public void patchingStatus( String message ) { } @Override - public void patchingMod(File modFile) { + public void patchingMod( File modFile ) { } @Override - public synchronized void patchingEnded(boolean outcome, Exception e) { + public synchronized void patchingEnded( boolean outcome, Exception e ) { succeeded = outcome; done = true; } @@ -707,18 +525,26 @@ public class SlipstreamCLI { } - private record ModAndDirFileFilter(boolean allowZip, boolean allowDirs) implements FileFilter { + + private static class ModAndDirFileFilter implements FileFilter { + private boolean allowZip; + private boolean allowDirs; + + public ModAndDirFileFilter( boolean allowZip, boolean allowDirs ) { + this.allowZip = allowZip; + this.allowDirs = allowDirs; + } @Override - public boolean accept(File f) { - if (f.isDirectory()) return allowDirs; + public boolean accept( File f ) { + if ( f.isDirectory() ) return allowDirs; - if (f.getName().endsWith(".ftl")) return true; + if ( f.getName().endsWith(".ftl") ) return true; - if (allowZip) { - return f.getName().endsWith(".zip"); - } - return false; + if ( allowZip ) { + if ( f.getName().endsWith(".zip") ) return true; } + return false; } + } } diff --git a/src/main/java/net/vhati/modmanager/ui/ClipboardMenuMouseListener.java b/src/main/java/net/vhati/modmanager/ui/ClipboardMenuMouseListener.java new file mode 100644 index 0000000..0a15eec --- /dev/null +++ b/src/main/java/net/vhati/modmanager/ui/ClipboardMenuMouseListener.java @@ -0,0 +1,104 @@ +package net.vhati.modmanager.ui; + +import java.awt.Toolkit; +import java.awt.datatransfer.DataFlavor; +import java.awt.event.ActionEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import javax.swing.AbstractAction; +import javax.swing.Action; +import javax.swing.JMenuItem; +import javax.swing.JPopupMenu; +import javax.swing.text.JTextComponent; + + +/** + * A Cut/Copy/Paste/SelectAll context menu for JTextComponents. + * + * Add this listener after any others. Any pressed/released listeners that want + * to preempt this menu should call e.consume(). + */ +public class ClipboardMenuMouseListener extends MouseAdapter { + + private JPopupMenu popup = new JPopupMenu(); + + private Action cutAction; + private Action copyAction; + private Action pasteAction; + private Action selectAllAction; + + private JTextComponent textComponent = null; + + + public ClipboardMenuMouseListener() { + cutAction = new AbstractAction( "Cut" ) { + @Override + public void actionPerformed( ActionEvent ae ) { + textComponent.cut(); + } + }; + copyAction = new AbstractAction( "Copy" ) { + @Override + public void actionPerformed( ActionEvent ae ) { + textComponent.copy(); + } + }; + pasteAction = new AbstractAction( "Paste" ) { + @Override + public void actionPerformed( ActionEvent ae ) { + textComponent.paste(); + } + }; + selectAllAction = new AbstractAction( "Select All" ) { + @Override + public void actionPerformed( ActionEvent ae ) { + textComponent.selectAll(); + } + }; + + popup.add( cutAction ); + popup.add( copyAction ); + popup.add( pasteAction ); + popup.addSeparator(); + popup.add( selectAllAction ); + } + + + @Override + public void mousePressed( MouseEvent e ) { + if ( e.isConsumed() ) return; + if ( e.isPopupTrigger() ) showMenu( e ); + } + + @Override + public void mouseReleased( MouseEvent e ) { + if ( e.isConsumed() ) return; + if ( e.isPopupTrigger() ) showMenu( e ); + } + + public void showMenu( MouseEvent e ) { + if ( e.getSource() instanceof JTextComponent == false ) return; + + textComponent = (JTextComponent)e.getSource(); + textComponent.requestFocus(); + + boolean enabled = textComponent.isEnabled(); + boolean editable = textComponent.isEditable(); + boolean nonempty = !(textComponent.getText() == null || textComponent.getText().equals( "" )); + boolean marked = textComponent.getSelectedText() != null; + + boolean pasteAvailable = Toolkit.getDefaultToolkit().getSystemClipboard().getContents( null ).isDataFlavorSupported( DataFlavor.stringFlavor ); + + cutAction.setEnabled( enabled && editable && marked ); + copyAction.setEnabled( enabled && marked ); + pasteAction.setEnabled( enabled && editable && pasteAvailable ); + selectAllAction.setEnabled( enabled && nonempty ); + + int nx = e.getX(); + if ( nx > 500 ) nx = nx - popup.getSize().width; + + popup.show( e.getComponent(), nx, e.getY() - popup.getSize().height ); + + e.consume(); + } +} \ No newline at end of file diff --git a/src/main/java/net/vhati/modmanager/ui/CreateModDialog.java b/src/main/java/net/vhati/modmanager/ui/CreateModDialog.java new file mode 100644 index 0000000..bda5804 --- /dev/null +++ b/src/main/java/net/vhati/modmanager/ui/CreateModDialog.java @@ -0,0 +1,213 @@ +package net.vhati.modmanager.ui; + +import java.awt.BorderLayout; +import java.awt.Desktop; +import java.awt.Dimension; +import java.awt.Frame; +import java.awt.Point; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.io.File; +import java.io.IOException; +import javax.swing.BorderFactory; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JDialog; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTextField; +import javax.swing.ScrollPaneConstants; +import javax.swing.event.AncestorEvent; +import javax.swing.event.AncestorListener; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import net.vhati.modmanager.ui.FieldEditorPanel; +import net.vhati.modmanager.ui.FieldEditorPanel.ContentType; +import net.vhati.modmanager.ui.RegexDocument; +import net.vhati.modmanager.xml.JDOMModMetadataWriter; + + +public class CreateModDialog extends JDialog implements ActionListener { + + private static final Logger log = LoggerFactory.getLogger( CreateModDialog.class ); + + protected static final String DIR_NAME = "Directory Name"; + protected static final String AUDIO_ROOT = "audio/"; + protected static final String DATA_ROOT = "data/"; + protected static final String FONTS_ROOT = "fonts/"; + protected static final String IMG_ROOT = "img/"; + protected static final String XML_COMMENTS = "XML Comments"; + protected static final String TITLE = "Title"; + protected static final String URL = "Thread URL"; + protected static final String AUTHOR = "Author"; + protected static final String VERSION = "Version"; + protected static final String DESC = "Description"; + + protected FieldEditorPanel editorPanel; + protected JButton applyBtn; + + protected File modsDir; + + + public CreateModDialog( Frame owner, File modsDir ) { + super( owner, "New Mod" ); + this.setDefaultCloseOperation( JDialog.DISPOSE_ON_CLOSE ); + + this.modsDir = modsDir; + + editorPanel = new FieldEditorPanel( false ); + editorPanel.setBorder( BorderFactory.createEmptyBorder( 10, 10, 0, 10 ) ); + editorPanel.setNameWidth( 100 ); + editorPanel.setValueWidth( 350 ); + + editorPanel.addRow( DIR_NAME, ContentType.STRING ); + editorPanel.getString( DIR_NAME ).setDocument( new RegexDocument( "[^\\/:;*?<>|^\"]*" ) ); + editorPanel.addTextRow( String.format( "The name of a directory to create in the %s/ folder.", modsDir.getName() ) ); + editorPanel.addSeparatorRow(); + editorPanel.addRow( AUDIO_ROOT, ContentType.BOOLEAN ); + editorPanel.addRow( DATA_ROOT, ContentType.BOOLEAN ); + editorPanel.addRow( FONTS_ROOT, ContentType.BOOLEAN ); + editorPanel.addRow( IMG_ROOT, ContentType.BOOLEAN ); + editorPanel.getBoolean( AUDIO_ROOT ).setHorizontalAlignment( javax.swing.SwingConstants.LEFT ); + editorPanel.getBoolean( DATA_ROOT ).setHorizontalAlignment( javax.swing.SwingConstants.LEFT ); + editorPanel.getBoolean( FONTS_ROOT ).setHorizontalAlignment( javax.swing.SwingConstants.LEFT ); + editorPanel.getBoolean( IMG_ROOT ).setHorizontalAlignment( javax.swing.SwingConstants.LEFT ); + editorPanel.addTextRow( "Create empty top-level directories?" ); + editorPanel.addSeparatorRow(); + editorPanel.addRow( XML_COMMENTS, ContentType.BOOLEAN ); + editorPanel.getBoolean( XML_COMMENTS ).setHorizontalAlignment( javax.swing.SwingConstants.LEFT ); + editorPanel.addTextRow( "Include XML comments about the purpose of these fields?" ); + editorPanel.addSeparatorRow(); + editorPanel.addRow( TITLE, ContentType.STRING ); + editorPanel.addTextRow( "The title of this mod." ); + editorPanel.addSeparatorRow(); + editorPanel.addRow( URL, ContentType.STRING ); + editorPanel.addTextRow( "This mod's thread on the forum." ); + editorPanel.addSeparatorRow(); + editorPanel.addRow( AUTHOR, ContentType.STRING ); + editorPanel.addTextRow( "Your forum user name." ); + editorPanel.addSeparatorRow(); + editorPanel.addRow( VERSION, ContentType.STRING ); + editorPanel.addTextRow( "The revision/variant of this release, preferably at least a number." ); + editorPanel.addSeparatorRow(); + editorPanel.addRow( DESC, ContentType.TEXT_AREA ); + editorPanel.getTextArea( DESC ).setRows( 15 ); + editorPanel.addTextRow( "Summary of gameplay effects; flavor; features; concerns about compatibility, recommended patch order, requirements; replaced ship slot; etc." ); + + editorPanel.getBoolean( XML_COMMENTS ).setSelected( true ); + + JPanel ctrlPanel = new JPanel(); + ctrlPanel.setLayout( new BoxLayout( ctrlPanel, BoxLayout.X_AXIS ) ); + ctrlPanel.setBorder( BorderFactory.createEmptyBorder( 10, 0, 10, 0 ) ); + ctrlPanel.add( Box.createHorizontalGlue() ); + applyBtn = new JButton( "Generate Mod" ); + applyBtn.addActionListener( this ); + ctrlPanel.add( applyBtn ); + ctrlPanel.add( Box.createHorizontalGlue() ); + + final JScrollPane editorScroll = new JScrollPane( editorPanel ); + editorScroll.setVerticalScrollBarPolicy( ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS ); + editorScroll.getVerticalScrollBar().setUnitIncrement( 10 ); + int vbarWidth = editorScroll.getVerticalScrollBar().getPreferredSize().width; + editorScroll.setPreferredSize( new Dimension( editorPanel.getPreferredSize().width+vbarWidth+5, 400 ) ); + + JPanel contentPane = new JPanel( new BorderLayout() ); + contentPane.add( editorScroll, BorderLayout.CENTER ); + contentPane.add( ctrlPanel, BorderLayout.SOUTH ); + this.setContentPane( contentPane ); + this.pack(); + this.setMinimumSize( new Dimension( 250, 250 ) ); + + + editorScroll.addAncestorListener(new AncestorListener() { + @Override + public void ancestorAdded( AncestorEvent e ) { + editorScroll.getViewport().setViewPosition( new Point( 0, 0 ) ); + } + @Override + public void ancestorMoved( AncestorEvent e ) { + } + @Override + public void ancestorRemoved( AncestorEvent e ) { + } + }); + } + + @Override + public void actionPerformed( ActionEvent e ) { + Object source = e.getSource(); + + if ( source == applyBtn ) { + String dirName = editorPanel.getString( DIR_NAME ).getText().trim(); + String modTitle = editorPanel.getString( TITLE ).getText().trim(); + String modURL = editorPanel.getString( URL ).getText().trim(); + String modAuthor = editorPanel.getString( AUTHOR ).getText().trim(); + String modVersion = editorPanel.getString( VERSION ).getText().trim(); + String modDesc = editorPanel.getTextArea( DESC ).getText().trim(); + boolean xmlComments = editorPanel.getBoolean( XML_COMMENTS ).isSelected(); + + if ( dirName.length() == 0 ) { + JOptionPane.showMessageDialog( CreateModDialog.this, "No directory name was given.", "Nothing to do", JOptionPane.WARNING_MESSAGE ); + return; + } + + File genDir = new File( modsDir, dirName ); + if ( !genDir.exists() ) { + try { + // Generate the mod. + if ( genDir.mkdir() ) { + File appendixDir = new File ( genDir, "mod-appendix" ); + if ( appendixDir.mkdir() ) { + File metadataFile = new File( appendixDir, "metadata.xml" ); + + JDOMModMetadataWriter.writeMetadata( metadataFile, modTitle, modURL, modAuthor, modVersion, modDesc, xmlComments ); + } + else { + throw new IOException( String.format( "Failed to create directory: %s", appendixDir.getName() ) ); + } + } + else { + throw new IOException( String.format( "Failed to create directory: %s", genDir.getName() ) ); + } + + // Create root dirs. + if ( editorPanel.getBoolean( AUDIO_ROOT ).isSelected() ) + new File( genDir, "audio" ).mkdir(); + if ( editorPanel.getBoolean( DATA_ROOT ).isSelected() ) + new File( genDir, "data" ).mkdir(); + if ( editorPanel.getBoolean( FONTS_ROOT ).isSelected() ) + new File( genDir, "fonts" ).mkdir(); + if ( editorPanel.getBoolean( IMG_ROOT ).isSelected() ) + new File( genDir, "img" ).mkdir(); + + // Show the folder. + try { + if ( Desktop.isDesktopSupported() ) { + Desktop.getDesktop().open( genDir.getCanonicalFile() ); + } else { + log.error( String.format( "Java cannot open the %s/ folder for you on this OS", genDir.getName() ) ); + } + } + catch ( IOException f ) { + log.error( String.format( "Error opening %s/ folder", genDir.getName() ), f ); + } + + // All done. + CreateModDialog.this.dispose(); + } + catch ( IOException f ) { + log.error( String.format( "Failed to generate new mod: %s", genDir.getName() ), f ); + + JOptionPane.showMessageDialog( CreateModDialog.this, String.format( "Failed to generate new mod: %s\n%s", genDir.getName(), f.getMessage() ), "Error", JOptionPane.ERROR_MESSAGE ); + } + } + else { + JOptionPane.showMessageDialog( CreateModDialog.this, String.format( "A directory named \"%s\" already exists.", genDir.getName() ), "Nothing to do", JOptionPane.WARNING_MESSAGE ); + } + } + } +} diff --git a/src/main/java/net/vhati/modmanager/ui/DatExtractDialog.java b/src/main/java/net/vhati/modmanager/ui/DatExtractDialog.java new file mode 100644 index 0000000..eeb4151 --- /dev/null +++ b/src/main/java/net/vhati/modmanager/ui/DatExtractDialog.java @@ -0,0 +1,163 @@ +package net.vhati.modmanager.ui; + +import java.awt.Frame; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import javax.swing.JDialog; +import javax.swing.SwingUtilities; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import net.vhati.ftldat.AbstractPack; +import net.vhati.ftldat.FolderPack; +import net.vhati.ftldat.FTLPack; +import net.vhati.ftldat.PkgPack; + +import net.vhati.modmanager.ui.ProgressDialog; + + +public class DatExtractDialog extends ProgressDialog { + + private static final Logger log = LoggerFactory.getLogger( DatExtractDialog.class ); + + private boolean started = false; + + private File extractDir = null; + private File datsDir = null; + + private DatExtractThread workerThread = null; + + + public DatExtractDialog( Frame owner, File extractDir, File datsDir ) { + super( owner, false ); + this.setTitle( "Extracting..." ); + + this.extractDir = extractDir; + this.datsDir = datsDir; + + this.setSize( 400, 160 ); + this.setMinimumSize( this.getPreferredSize() ); + this.setLocationRelativeTo( owner ); + + workerThread = new DatExtractThread( extractDir, datsDir ); + } + + /** + * Returns the worker thread that does the extracting. + * + * This method is provided so other classes can customize the thread + * before calling extract(). + */ + public Thread getWorkerThread() { + return workerThread; + } + + /** + * Starts the background extraction thread. + * Call this immediately before setVisible(). + */ + public void extract() { + if ( started ) return; + + workerThread.start(); + started = true; + } + + @Override + protected void setTaskOutcome( boolean outcome, Exception e ) { + super.setTaskOutcome( outcome, e ); + if ( !this.isShowing() ) return; + + if ( succeeded ) { + setStatusText( "All resources extracted successfully." ); + } else { + setStatusText( String.format( "Error extracting dats: %s", e ) ); + } + } + + + + private class DatExtractThread extends Thread { + + private File extractDir = null; + private File datsDir = null; + + + public DatExtractThread( File extractDir, File datsDir ) { + this.extractDir = extractDir; + this.datsDir = datsDir; + } + + @Override + public void run() { + AbstractPack dstPack = null; + List srcPacks = new ArrayList( 2 ); + InputStream is = null; + int progress = 0; + + try { + File ftlDatFile = new File( datsDir, "ftl.dat" ); + File dataDatFile = new File( datsDir, "data.dat" ); + File resourceDatFile = new File( datsDir, "resource.dat" ); + + if ( ftlDatFile.exists() ) { // FTL 1.6.1. + AbstractPack ftlPack = new PkgPack( ftlDatFile, "r" ); + srcPacks.add( ftlPack ); + } + else if ( dataDatFile.exists() && resourceDatFile.exists() ) { // FTL 1.01-1.5.13. + AbstractPack dataPack = new FTLPack( dataDatFile, "r" ); + AbstractPack resourcePack = new FTLPack( resourceDatFile, "r" ); + srcPacks.add( dataPack ); + srcPacks.add( resourcePack ); + } + else { + throw new FileNotFoundException( String.format( "Could not find either \"%s\" or both \"%s\" and \"%s\"", ftlDatFile.getName(), dataDatFile.getName(), resourceDatFile.getName() ) ); + } + + if ( !extractDir.exists() ) extractDir.mkdirs(); + + dstPack = new FolderPack( extractDir ); + + for ( AbstractPack srcPack : srcPacks ) { + progress = 0; + List innerPaths = srcPack.list(); + setProgressLater( progress, innerPaths.size() ); + + for ( String innerPath : innerPaths ) { + setStatusTextLater( innerPath ); + if ( dstPack.contains( innerPath ) ) { + log.info( "While extracting resources, this file was overwritten: "+ innerPath ); + dstPack.remove( innerPath ); + } + is = srcPack.getInputStream( innerPath ); + dstPack.add( innerPath, is ); + setProgressLater( progress++ ); + } + srcPack.close(); + } + setTaskOutcomeLater( true, null ); + } + catch ( Exception e ) { + log.error( "Error extracting dats", e ); + setTaskOutcomeLater( false, e ); + } + finally { + try {if ( is != null ) is.close();} + catch ( IOException e ) {} + + try {if ( dstPack != null ) dstPack.close();} + catch ( IOException e ) {} + + for ( AbstractPack pack : srcPacks ) { + try {pack.close();} + catch ( IOException ex ) {} + } + } + } + } +} diff --git a/src/main/java/net/vhati/modmanager/ui/FieldEditorPanel.java b/src/main/java/net/vhati/modmanager/ui/FieldEditorPanel.java new file mode 100644 index 0000000..ee4a149 --- /dev/null +++ b/src/main/java/net/vhati/modmanager/ui/FieldEditorPanel.java @@ -0,0 +1,457 @@ +package net.vhati.modmanager.ui; + +import java.awt.Component; +import java.awt.Dimension; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.util.HashMap; +import java.util.Map; +import javax.swing.BorderFactory; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JComboBox; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JSeparator; +import javax.swing.JSlider; +import javax.swing.JTextArea; +import javax.swing.JTextField; +import javax.swing.SwingConstants; +import javax.swing.UIManager; +import javax.swing.event.ChangeEvent; +import javax.swing.event.ChangeListener; + +import net.vhati.modmanager.ui.RegexDocument; + + +public class FieldEditorPanel extends JPanel { + public enum ContentType { WRAPPED_LABEL, LABEL, STRING, TEXT_AREA, INTEGER, BOOLEAN, SLIDER, COMBO, CHOOSER } + + private Map wrappedLabelMap = new HashMap(); + private Map labelMap = new HashMap(); + private Map stringMap = new HashMap(); + private Map textAreaMap = new HashMap(); + private Map intMap = new HashMap(); + private Map boolMap = new HashMap(); + private Map sliderMap = new HashMap(); + private Map comboMap = new HashMap(); + private Map chooserMap = new HashMap(); + private Map reminderMap = new HashMap(); + + private GridBagConstraints gridC = new GridBagConstraints(); + + private Component nameStrut = Box.createHorizontalStrut( 1 ); + private Component valueStrut = Box.createHorizontalStrut( 120 ); + private Component reminderStrut = Box.createHorizontalStrut( 90 ); + + private boolean remindersVisible; + + + public FieldEditorPanel( boolean remindersVisible ) { + super( new GridBagLayout() ); + this.remindersVisible = remindersVisible; + + gridC.anchor = GridBagConstraints.WEST; + gridC.fill = GridBagConstraints.HORIZONTAL; + gridC.weightx = 0.0; + gridC.weighty = 0.0; + gridC.gridwidth = 1; + gridC.gridx = 0; + gridC.gridy = 0; + + // No default width for col 0. + gridC.gridx = 0; + this.add( nameStrut, gridC ); + gridC.gridx++; + this.add( valueStrut, gridC ); + gridC.gridx++; + if ( remindersVisible ) { + this.add( reminderStrut, gridC ); + gridC.gridy++; + } + + gridC.insets = new Insets( 2, 4, 2, 4 ); + } + + + public void setNameWidth( int width ) { + nameStrut.setMinimumSize( new Dimension( width, 0 ) ); + nameStrut.setPreferredSize( new Dimension( width, 0 ) ); + } + + public void setValueWidth( int width ) { + valueStrut.setMinimumSize( new Dimension( width, 0 ) ); + valueStrut.setPreferredSize( new Dimension( width, 0 ) ); + } + + public void setReminderWidth( int width ) { + reminderStrut.setMinimumSize( new Dimension( width, 0 ) ); + reminderStrut.setPreferredSize( new Dimension( width, 0 ) ); + } + + + /** + * Constructs JComponents for a given type of value. + * A row consists of a static label, some JComponent, + * and a reminder label. + * + * The component and reminder will be accessable later + * via getter methods. + */ + public void addRow( String valueName, ContentType contentType ) { + gridC.fill = GridBagConstraints.HORIZONTAL; + gridC.gridwidth = 1; + gridC.weighty = 0.0; + gridC.gridx = 0; + this.add( new JLabel( valueName +":" ), gridC ); + + gridC.gridx++; + if ( contentType == ContentType.WRAPPED_LABEL ) { + gridC.anchor = GridBagConstraints.WEST; + JTextArea valueArea = new JTextArea(); + valueArea.setBackground( null ); + valueArea.setEditable( false ); + valueArea.setBorder( null ); + valueArea.setLineWrap( true ); + valueArea.setWrapStyleWord( true ); + valueArea.setFocusable( false ); + valueArea.setFont( UIManager.getFont( "Label.font" ) ); + + wrappedLabelMap.put( valueName, valueArea ); + this.add( valueArea, gridC ); + } + else if ( contentType == ContentType.LABEL ) { + gridC.anchor = GridBagConstraints.WEST; + JLabel valueLbl = new JLabel(); + valueLbl.setHorizontalAlignment( SwingConstants.CENTER ); + labelMap.put( valueName, valueLbl ); + this.add( valueLbl, gridC ); + } + else if ( contentType == ContentType.STRING ) { + gridC.anchor = GridBagConstraints.WEST; + JTextField valueField = new JTextField(); + stringMap.put( valueName, valueField ); + this.add( valueField, gridC ); + } + else if ( contentType == ContentType.TEXT_AREA ) { + gridC.anchor = GridBagConstraints.WEST; + JTextArea valueArea = new JTextArea(); + valueArea.setEditable( true ); + valueArea.setBorder( BorderFactory.createCompoundBorder( BorderFactory.createEtchedBorder(), BorderFactory.createEmptyBorder( 2, 2, 2, 2 ) ) ); + valueArea.setLineWrap( true ); + valueArea.setWrapStyleWord( true ); + valueArea.setFocusable( true ); + valueArea.setFont( UIManager.getFont( "TextField.font" ) ); // Override small default font on systemLaf. + + textAreaMap.put( valueName, valueArea ); + this.add( valueArea, gridC ); + } + else if ( contentType == ContentType.INTEGER ) { + gridC.anchor = GridBagConstraints.WEST; + JTextField valueField = new JTextField(); + valueField.setHorizontalAlignment( JTextField.RIGHT ); + valueField.setDocument( new RegexDocument( "[0-9]*" ) ); + intMap.put( valueName, valueField ); + this.add( valueField, gridC ); + } + else if ( contentType == ContentType.BOOLEAN ) { + gridC.anchor = GridBagConstraints.CENTER; + JCheckBox valueCheck = new JCheckBox(); + valueCheck.setHorizontalAlignment( SwingConstants.CENTER ); + boolMap.put( valueName, valueCheck ); + this.add( valueCheck, gridC ); + } + else if ( contentType == ContentType.SLIDER ) { + gridC.anchor = GridBagConstraints.CENTER; + JPanel panel = new JPanel(); + panel.setLayout( new BoxLayout( panel, BoxLayout.X_AXIS ) ); + final JSlider valueSlider = new JSlider( JSlider.HORIZONTAL ); + valueSlider.setPreferredSize( new Dimension( 50, valueSlider.getPreferredSize().height ) ); + sliderMap.put( valueName, valueSlider ); + panel.add( valueSlider ); + final JTextField valueField = new JTextField( 3 ); + valueField.setMaximumSize( valueField.getPreferredSize() ); + valueField.setHorizontalAlignment( JTextField.RIGHT ); + valueField.setEditable( false ); + panel.add( valueField ); + this.add( panel, gridC ); + + valueSlider.addChangeListener(new ChangeListener() { + @Override + public void stateChanged( ChangeEvent e ) { + valueField.setText( ""+valueSlider.getValue() ); + } + }); + } + else if ( contentType == ContentType.COMBO ) { + gridC.anchor = GridBagConstraints.CENTER; + JComboBox valueCombo = new JComboBox(); + valueCombo.setEditable( false ); + comboMap.put( valueName, valueCombo ); + this.add( valueCombo, gridC ); + } + else if ( contentType == ContentType.CHOOSER ) { + gridC.anchor = GridBagConstraints.WEST; + JPanel panel = new JPanel(); + panel.setLayout( new BoxLayout( panel, BoxLayout.X_AXIS ) ); + + JTextField chooserField = new JTextField(); + panel.add( chooserField ); + panel.add( Box.createHorizontalStrut( 5 ) ); + JButton chooserBtn = new JButton( "..." ); + chooserBtn.setMargin( new Insets( 1,2,1,2 ) ); + panel.add( chooserBtn ); + Chooser valueChooser = new Chooser( chooserField, chooserBtn ); + chooserMap.put( valueName, valueChooser ); + + this.add( panel, gridC ); + } + gridC.gridx++; + + if ( remindersVisible ) { + gridC.anchor = GridBagConstraints.WEST; + JLabel valueReminder = new JLabel(); + reminderMap.put( valueName, valueReminder ); + this.add( valueReminder, gridC ); + } + + gridC.gridy++; + } + + public void addTextRow( String text ) { + gridC.fill = GridBagConstraints.HORIZONTAL; + gridC.weighty = 0.0; + gridC.gridwidth = GridBagConstraints.REMAINDER; + gridC.gridx = 0; + + gridC.anchor = GridBagConstraints.WEST; + JTextArea textArea = new JTextArea( text ); + textArea.setBackground( null ); + textArea.setEditable( false ); + textArea.setBorder( null ); + textArea.setLineWrap( true ); + textArea.setWrapStyleWord( true ); + textArea.setFocusable( false ); + textArea.setFont( UIManager.getFont( "Label.font" ) ); + + this.add( textArea, gridC ); + gridC.gridy++; + } + + public void addSeparatorRow() { + gridC.fill = GridBagConstraints.HORIZONTAL; + gridC.weighty = 0.0; + gridC.gridwidth = GridBagConstraints.REMAINDER; + gridC.gridx = 0; + + JPanel panel = new JPanel(); + panel.setLayout( new BoxLayout( panel, BoxLayout.Y_AXIS ) ); + panel.add( Box.createVerticalStrut( 8 ) ); + JSeparator sep = new JSeparator(); + sep.setPreferredSize( new Dimension( 1, sep.getPreferredSize().height ) ); + panel.add( sep ); + panel.add( Box.createVerticalStrut( 8 ) ); + + this.add( panel, gridC ); + gridC.gridy++; + } + + public void addBlankRow() { + gridC.fill = GridBagConstraints.NONE; + gridC.weighty = 0.0; + gridC.gridwidth = GridBagConstraints.REMAINDER; + gridC.gridx = 0; + + this.add( Box.createVerticalStrut( 12 ), gridC ); + gridC.gridy++; + } + + public void addFillRow() { + gridC.fill = GridBagConstraints.VERTICAL; + gridC.weighty = 1.0; + gridC.gridwidth = GridBagConstraints.REMAINDER; + gridC.gridx = 0; + + this.add( Box.createVerticalGlue(), gridC ); + gridC.gridy++; + } + + + public void setStringAndReminder( String valueName, String s ) { + JTextField valueField = stringMap.get( valueName ); + if ( valueField != null ) valueField.setText( s ); + if ( remindersVisible ) setReminder( valueName, s ); + } + + public void setIntAndReminder( String valueName, int n ) { + setIntAndReminder( valueName, n, ""+n ); + } + public void setIntAndReminder( String valueName, int n, String s ) { + JTextField valueField = intMap.get( valueName ); + if ( valueField != null ) valueField.setText( ""+n ); + if ( remindersVisible ) setReminder( valueName, s ); + } + + public void setBoolAndReminder( String valueName, boolean b ) { + setBoolAndReminder( valueName, b, ""+b ); + } + public void setBoolAndReminder( String valueName, boolean b, String s ) { + JCheckBox valueCheck = boolMap.get( valueName ); + if ( valueCheck != null ) valueCheck.setSelected( b ); + if ( remindersVisible ) setReminder( valueName, s ); + } + + public void setSliderAndReminder( String valueName, int n ) { + setSliderAndReminder( valueName, n, ""+n ); + } + public void setSliderAndReminder( String valueName, int n, String s ) { + JSlider valueSlider = sliderMap.get( valueName ); + if ( valueSlider != null ) valueSlider.setValue( n ); + if ( remindersVisible ) setReminder( valueName, s ); + } + + public void setComboAndReminder( String valueName, Object o ) { + setComboAndReminder( valueName, o, o.toString() ); + } + public void setComboAndReminder( String valueName, Object o, String s ) { + JComboBox valueCombo = comboMap.get( valueName ); + if ( valueCombo != null ) valueCombo.setSelectedItem( o ); + if ( remindersVisible ) setReminder( valueName, s ); + } + + public void setChooserAndReminder( String valueName, String s ) { + Chooser valueChooser = chooserMap.get( valueName ); + if ( valueChooser != null ) valueChooser.getTextField().setText( s ); + if ( remindersVisible ) setReminder( valueName, s ); + } + + public void setReminder( String valueName, String s ) { + JLabel valueReminder = reminderMap.get( valueName ); + if ( valueReminder != null ) valueReminder.setText( "( "+ s +" )" ); + } + + public JTextArea getWrappedLabel( String valueName ) { + return wrappedLabelMap.get( valueName ); + } + + public JLabel getLabel( String valueName ) { + return labelMap.get( valueName ); + } + + public JTextField getString( String valueName ) { + return stringMap.get( valueName ); + } + + public JTextArea getTextArea( String valueName ) { + return textAreaMap.get( valueName ); + } + + public JTextField getInt( String valueName ) { + return intMap.get( valueName ); + } + + public JCheckBox getBoolean( String valueName ) { + return boolMap.get( valueName ); + } + + public JSlider getSlider( String valueName ) { + return sliderMap.get( valueName ); + } + + public JComboBox getCombo( String valueName ) { + return comboMap.get( valueName ); + } + + public Chooser getChooser( String valueName ) { + return chooserMap.get( valueName ); + } + + + public void reset() { + for ( JTextArea valueArea : wrappedLabelMap.values() ) + valueArea.setText( "" ); + + for ( JLabel valueLbl : labelMap.values() ) + valueLbl.setText( "" ); + + for ( JTextField valueField : stringMap.values() ) + valueField.setText( "" ); + + for ( JTextArea valueArea : textAreaMap.values() ) + valueArea.setText( "" ); + + for ( JTextField valueField : intMap.values() ) + valueField.setText( "" ); + + for ( JCheckBox valueCheck : boolMap.values() ) + valueCheck.setSelected( false ); + + for ( JSlider valueSlider : sliderMap.values() ) + valueSlider.setValue( 0 ); + + for ( JComboBox valueCombo : comboMap.values() ) + valueCombo.removeAllItems(); + + for ( Chooser valueChooser : chooserMap.values() ) + valueChooser.getTextField().setText( "" ); + + for ( JLabel valueReminder : reminderMap.values() ) + valueReminder.setText( "" ); + } + + @Override + public void removeAll() { + wrappedLabelMap.clear(); + labelMap.clear(); + stringMap.clear(); + textAreaMap.clear(); + intMap.clear(); + boolMap.clear(); + sliderMap.clear(); + comboMap.clear(); + chooserMap.clear(); + reminderMap.clear(); + super.removeAll(); + gridC = new GridBagConstraints(); + + gridC.anchor = GridBagConstraints.WEST; + gridC.fill = GridBagConstraints.HORIZONTAL; + gridC.weightx = 0.0; + gridC.weighty = 0.0; + gridC.gridwidth = 1; + gridC.gridx = 0; + gridC.gridy = 0; + + // No default width for col 0. + gridC.gridx = 0; + this.add( Box.createVerticalStrut( 1 ), gridC ); + gridC.gridx++; + this.add( valueStrut, gridC ); + gridC.gridx++; + if ( remindersVisible ) { + this.add( reminderStrut, gridC ); + gridC.gridy++; + } + + gridC.insets = new Insets( 2, 4, 2, 4 ); + } + + + + public static class Chooser { + private JTextField textField; + private JButton button; + + public Chooser( JTextField textField, JButton button ) { + this.textField = textField; + this.button = button; + } + + public JTextField getTextField() { return textField; } + public JButton getButton() { return button; } + } +} diff --git a/src/main/java/net/vhati/modmanager/ui/InertPanel.java b/src/main/java/net/vhati/modmanager/ui/InertPanel.java new file mode 100644 index 0000000..df73ec0 --- /dev/null +++ b/src/main/java/net/vhati/modmanager/ui/InertPanel.java @@ -0,0 +1,62 @@ +package net.vhati.modmanager.ui; + +import java.awt.Component; +import java.awt.Cursor; +import java.awt.KeyEventDispatcher; +import java.awt.KeyboardFocusManager; +import java.awt.Window; +import java.awt.event.KeyAdapter; +import java.awt.event.KeyEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import javax.swing.JFrame; +import javax.swing.JPanel; +import javax.swing.SwingUtilities; + + +/** + * A panel that consumes all mouse/keyboard events, for use as a glass pane. + */ +public class InertPanel extends JPanel { + + private KeyEventDispatcher nullDispatcher; + + + public InertPanel() { + super(); + + nullDispatcher = new KeyEventDispatcher() { + @Override + public boolean dispatchKeyEvent( KeyEvent e ) { + Object source = e.getSource(); + if ( source instanceof Component == false ) return false; + + Window ancestor = SwingUtilities.getWindowAncestor( (Component)source ); + if ( ancestor instanceof JFrame == false ) return false; + + return ( InertPanel.this == ((JFrame)ancestor).getGlassPane() ); + } + }; + + this.addMouseListener(new MouseAdapter() { + @Override + public void mousePressed( MouseEvent e ) {e.consume();} + @Override + public void mouseReleased( MouseEvent e ) {e.consume();} + }); + + this.setCursor(Cursor.getPredefinedCursor( Cursor.WAIT_CURSOR )); + this.setOpaque( false ); + } + + + @Override + public void setVisible( boolean b ) { + super.setVisible( b ); + if ( b ) { + KeyboardFocusManager.getCurrentKeyboardFocusManager().addKeyEventDispatcher( nullDispatcher ); + } else { + KeyboardFocusManager.getCurrentKeyboardFocusManager().removeKeyEventDispatcher( nullDispatcher ); + } + } +} diff --git a/src/main/java/net/vhati/modmanager/ui/ManagerFrame.java b/src/main/java/net/vhati/modmanager/ui/ManagerFrame.java new file mode 100644 index 0000000..742e6f7 --- /dev/null +++ b/src/main/java/net/vhati/modmanager/ui/ManagerFrame.java @@ -0,0 +1,1171 @@ +package net.vhati.modmanager.ui; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.Desktop; +import java.awt.Dimension; +import java.awt.Insets; +import java.awt.Rectangle; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.KeyEvent; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileFilter; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.nio.charset.Charset; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.swing.BorderFactory; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JFileChooser; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JMenu; +import javax.swing.JMenuBar; +import javax.swing.JMenuItem; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JSeparator; +import javax.swing.JSplitPane; +import javax.swing.JTable; +import javax.swing.JTextArea; +import javax.swing.SwingUtilities; +import javax.swing.UIManager; +import javax.swing.event.ListSelectionEvent; +import javax.swing.event.ListSelectionListener; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import net.vhati.modmanager.core.AutoUpdateInfo; +import net.vhati.modmanager.core.ComparableVersion; +import net.vhati.modmanager.core.FTLUtilities; +import net.vhati.modmanager.core.ModDB; +import net.vhati.modmanager.core.ModFileInfo; +import net.vhati.modmanager.core.ModInfo; +import net.vhati.modmanager.core.ModPatchThread; +import net.vhati.modmanager.core.ModsScanObserver; +import net.vhati.modmanager.core.ModsScanThread; +import net.vhati.modmanager.core.ModUtilities; +import net.vhati.modmanager.core.Report; +import net.vhati.modmanager.core.Report.ReportFormatter; +import net.vhati.modmanager.core.SlipstreamConfig; +import net.vhati.modmanager.json.JacksonCatalogWriter; +import net.vhati.modmanager.json.URLFetcher; +import net.vhati.modmanager.ui.InertPanel; +import net.vhati.modmanager.ui.ManagerInitThread; +import net.vhati.modmanager.ui.ModInfoArea; +import net.vhati.modmanager.ui.ModPatchDialog; +import net.vhati.modmanager.ui.ModXMLSandbox; +import net.vhati.modmanager.ui.SlipstreamConfigDialog; +import net.vhati.modmanager.ui.Statusbar; +import net.vhati.modmanager.ui.StatusbarMouseListener; +import net.vhati.modmanager.ui.table.ChecklistTablePanel; +import net.vhati.modmanager.ui.table.ListState; + + +public class ManagerFrame extends JFrame implements ActionListener, ModsScanObserver, Nerfable, Statusbar, Thread.UncaughtExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger( ManagerFrame.class ); + + public static final String CATALOG_URL = "https://raw.github.com/Vhati/Slipstream-Mod-Manager/master/skel_common/backup/current_catalog.json"; + public static final String APP_UPDATE_URL = "https://raw.github.com/Vhati/Slipstream-Mod-Manager/master/skel_common/backup/auto_update.json"; + + private File backupDir = new File( "./backup/" ); + private File modsDir = new File( "./mods/" ); + + private File modsTableStateFile = new File( modsDir, "modorder.txt" ); + + private File metadataFile = new File( backupDir, "cached_metadata.json" ); + + private File catalogFile = new File( backupDir, "current_catalog.json" ); + private File catalogETagFile = new File( backupDir, "current_catalog_etag.txt" ); + + private File appUpdateFile = new File( backupDir, "auto_update.json" ); + private File appUpdateETagFile = new File( backupDir, "auto_update_etag.txt" ); + + private boolean disposeNormally = true; + private boolean ranInit = false; + private Thread.UncaughtExceptionHandler previousUncaughtExceptionHandler = null; + + private final Lock managerLock = new ReentrantLock(); + private final Condition scanEndedCond = managerLock.newCondition(); + private boolean scanning = false; + + private SlipstreamConfig appConfig; + private String appName; + private ComparableVersion appVersion; + private String appURL; + private String appAuthor; + + private Map modFileHashes = new HashMap(); + private Map modFileDates = new HashMap(); + private ModDB catalogModDB = new ModDB(); + private ModDB localModDB = new ModDB(); + + private AutoUpdateInfo appUpdateInfo = null; + private Color updateBtnDisabledColor = UIManager.getColor( "Button.foreground" ); + private Color updateBtnEnabledColor = new Color( 0, 124, 0 ); + + private NerfListener nerfListener = new NerfListener( this ); + + private ChecklistTablePanel modsTablePanel; + + private JMenuBar menubar; + private JMenu fileMenu; + private JMenuItem rescanMenuItem; + private JMenuItem extractDatsMenuItem; + private JMenuItem createModMenuItem; + private JMenuItem sandboxMenuItem; + private JMenuItem configMenuItem; + private JMenuItem exitMenuItem; + private JMenu helpMenu; + private JMenuItem deleteBackupsMenuItem; + private JMenuItem steamVerifyIntegrityMenuItem; + private JMenuItem aboutMenuItem; + + private JButton patchBtn; + private JButton toggleAllBtn; + private JButton validateBtn; + private JButton modsFolderBtn; + private JButton updateBtn; + private JSplitPane splitPane; + private ModInfoArea infoArea; + + private JLabel statusLbl; + + + public ManagerFrame( SlipstreamConfig appConfig, String appName, ComparableVersion appVersion, String appURL, String appAuthor ) { + super(); + this.appConfig = appConfig; + this.appName = appName; + this.appVersion = appVersion; + this.appURL = appURL; + this.appAuthor = appAuthor; + + this.setTitle( String.format( "%s v%s", appName, appVersion ) ); + this.setDefaultCloseOperation( JFrame.DO_NOTHING_ON_CLOSE ); + + JPanel contentPane = new JPanel( new BorderLayout() ); + + JPanel mainPane = new JPanel( new BorderLayout() ); + contentPane.add( mainPane, BorderLayout.CENTER ); + + JPanel topPanel = new JPanel( new BorderLayout() ); + + modsTablePanel = new ChecklistTablePanel(); + topPanel.add( modsTablePanel, BorderLayout.CENTER ); + + JPanel modActionsPanel = new JPanel(); + modActionsPanel.setLayout( new BoxLayout( modActionsPanel, BoxLayout.Y_AXIS ) ); + modActionsPanel.setBorder( BorderFactory.createEmptyBorder( 5,5,5,5 ) ); + Insets actionInsets = new Insets( 5,10,5,10 ); + + patchBtn = new JButton( "Patch" ); + patchBtn.setMargin( actionInsets ); + patchBtn.addMouseListener( new StatusbarMouseListener( this, "Incorporate all selected mods into the game. Or revert to vanilla, if none are." ) ); + patchBtn.addActionListener( this ); + modActionsPanel.add( patchBtn ); + + toggleAllBtn = new JButton( "Toggle All" ); + toggleAllBtn.setMargin( actionInsets ); + toggleAllBtn.addMouseListener( new StatusbarMouseListener( this, "Select all mods, or none." ) ); + toggleAllBtn.addActionListener( this ); + modActionsPanel.add( toggleAllBtn ); + + validateBtn = new JButton( "Validate" ); + validateBtn.setMargin( actionInsets ); + validateBtn.addMouseListener( new StatusbarMouseListener( this, "Check selected mods for problems." ) ); + validateBtn.addActionListener( this ); + modActionsPanel.add( validateBtn ); + + modsFolderBtn = new JButton( "Open mods/" ); + modsFolderBtn.setMargin( actionInsets ); + modsFolderBtn.addMouseListener( new StatusbarMouseListener( this, String.format( "Open the %s/ folder.", modsDir.getName() ) ) ); + modsFolderBtn.addActionListener( this ); + modsFolderBtn.setEnabled( Desktop.isDesktopSupported() ); + modActionsPanel.add( modsFolderBtn ); + + updateBtn = new JButton( "Update" ); + updateBtn.setMargin( actionInsets ); + updateBtn.addMouseListener( new StatusbarMouseListener( this, String.format( "Show info about the latest version of %s.", appName ) ) ); + updateBtn.addActionListener( this ); + updateBtn.setForeground( updateBtnDisabledColor ); + updateBtn.setEnabled( false ); + modActionsPanel.add( updateBtn ); + + topPanel.add( modActionsPanel, BorderLayout.EAST ); + + JButton[] actionBtns = new JButton[] {patchBtn, toggleAllBtn, validateBtn, modsFolderBtn, updateBtn }; + int actionBtnWidth = Integer.MIN_VALUE; + int actionBtnHeight = Integer.MIN_VALUE; + for ( JButton btn : actionBtns ) { + actionBtnWidth = Math.max( actionBtnWidth, btn.getPreferredSize().width ); + actionBtnHeight = Math.max( actionBtnHeight, btn.getPreferredSize().height ); + } + for ( JButton btn : actionBtns ) { + Dimension size = new Dimension( actionBtnWidth, actionBtnHeight ); + btn.setPreferredSize( size ); + btn.setMinimumSize( size ); + btn.setMaximumSize( size ); + } + + infoArea = new ModInfoArea(); + infoArea.setPreferredSize( new Dimension( 504, 220 ) ); + infoArea.setStatusbar( this ); + + splitPane = new JSplitPane( JSplitPane.VERTICAL_SPLIT ); + splitPane.setTopComponent( topPanel ); + splitPane.setBottomComponent( infoArea ); + mainPane.add( splitPane, BorderLayout.CENTER ); + + JPanel statusPanel = new JPanel(); + statusPanel.setLayout( new BoxLayout( statusPanel, BoxLayout.Y_AXIS ) ); + statusPanel.setBorder( BorderFactory.createLoweredBevelBorder() ); + statusLbl = new JLabel(" "); + statusLbl.setBorder( BorderFactory.createEmptyBorder( 2, 4, 2, 4 ) ); + statusLbl.setAlignmentX( Component.LEFT_ALIGNMENT ); + statusPanel.add( statusLbl ); + contentPane.add( statusPanel, BorderLayout.SOUTH ); + + + this.addWindowListener(new WindowAdapter() { + @Override + public void windowClosing( WindowEvent e ) { + // The close button was clicked. + + // This is where an "Are you sure?" popup could go. + ManagerFrame.this.setVisible( false ); + ManagerFrame.this.dispose(); + + // The following would also trigger this callback. + //Window w = ...; + //w.getToolkit().getSystemEventQueue().postEvent( new WindowEvent(w, WindowEvent.WINDOW_CLOSING) ); + } + + @Override + public void windowClosed( WindowEvent e ) { + // dispose() was called. + + // Restore the previous exception handler. + if ( ranInit ) Thread.setDefaultUncaughtExceptionHandler( previousUncaughtExceptionHandler ); + + if ( !disposeNormally ) return; // Something bad happened. Exit quickly. + + ListState tableState = getCurrentModsTableState(); + saveModsTableState( tableState ); + + SlipstreamConfig appConfig = ManagerFrame.this.appConfig; + + if ( appConfig.getProperty( SlipstreamConfig.REMEMBER_GEOMETRY ).equals( "true" ) ) { + if ( ManagerFrame.this.getExtendedState() == JFrame.NORMAL ) { + Rectangle managerBounds = ManagerFrame.this.getBounds(); + int dividerLoc = splitPane.getDividerLocation(); + String geometry = String.format( "x,%d;y,%d;w,%d;h,%d;divider,%d", managerBounds.x, managerBounds.y, managerBounds.width, managerBounds.height, dividerLoc ); + appConfig.setProperty( SlipstreamConfig.MANAGER_GEOMETRY, geometry ); + } + } + + try { + appConfig.writeConfig(); + } + catch ( IOException f ) { + log.error( String.format( "Error writing config to \"%s\"", appConfig.getConfigFile().getName() ), f ); + } + + try { + JacksonCatalogWriter.write( localModDB.getCollatedModInfo(), metadataFile ); + } + catch ( IOException f ) { + log.error( String.format( "Error writing metadata from local mods to \"%s\"", metadataFile.getName() ), f ); + } + + System.gc(); + //System.exit( 0 ); // Don't do this (InterruptedException). Let EDT end gracefully. + } + }); + + // Highlighted row shows mod info. + modsTablePanel.getTable().getSelectionModel().addListSelectionListener(new ListSelectionListener() { + @Override + public void valueChanged( ListSelectionEvent e ) { + if ( e.getValueIsAdjusting() ) return; + + int row = modsTablePanel.getTable().getSelectedRow(); + if ( row == -1 ) return; + + ModFileInfo modFileInfo = modsTablePanel.getTableModel().getItem( row ); + showLocalModInfo( modFileInfo ); + } + }); + + menubar = new JMenuBar(); + fileMenu = new JMenu( "File" ); + fileMenu.setMnemonic( KeyEvent.VK_F ); + rescanMenuItem = new JMenuItem( "Re-Scan mods/" ); + rescanMenuItem.addMouseListener( new StatusbarMouseListener( this, "Check the mods/ folder for new files." ) ); + rescanMenuItem.addActionListener( this ); + fileMenu.add( rescanMenuItem ); + extractDatsMenuItem = new JMenuItem( "Extract Dats..." ); + extractDatsMenuItem.addMouseListener( new StatusbarMouseListener( this, "Extract FTL resources into a folder." ) ); + extractDatsMenuItem.addActionListener( this ); + fileMenu.add( extractDatsMenuItem ); + fileMenu.add( new JSeparator() ); + createModMenuItem = new JMenuItem( "New Mod..." ); + createModMenuItem.addMouseListener( new StatusbarMouseListener( this, "Generate boilerplace for a new mod." ) ); + createModMenuItem.addActionListener( this ); + fileMenu.add( createModMenuItem ); + sandboxMenuItem = new JMenuItem( "XML Sandbox..." ); + sandboxMenuItem.addMouseListener( new StatusbarMouseListener( this, "Experiment with advanced mod syntax." ) ); + sandboxMenuItem.addActionListener( this ); + fileMenu.add( sandboxMenuItem ); + fileMenu.add( new JSeparator() ); + configMenuItem = new JMenuItem( "Preferences..." ); + configMenuItem.addMouseListener( new StatusbarMouseListener( this, "Edit preferences." ) ); + configMenuItem.addActionListener( this ); + fileMenu.add( configMenuItem ); + fileMenu.add( new JSeparator() ); + exitMenuItem = new JMenuItem( "Exit" ); + exitMenuItem.addMouseListener( new StatusbarMouseListener( this, "Exit this application." ) ); + exitMenuItem.addActionListener( this ); + fileMenu.add( exitMenuItem ); + menubar.add( fileMenu ); + helpMenu = new JMenu( "Help" ); + helpMenu.setMnemonic( KeyEvent.VK_H ); + deleteBackupsMenuItem = new JMenuItem( "Delete Backups" ); + deleteBackupsMenuItem.addMouseListener( new StatusbarMouseListener( this, "Delete backed up resources." ) ); + deleteBackupsMenuItem.addActionListener( this ); + helpMenu.add( deleteBackupsMenuItem ); + steamVerifyIntegrityMenuItem = new JMenuItem( "Steam: Verify integrity of game files" ); + steamVerifyIntegrityMenuItem.addMouseListener( new StatusbarMouseListener( this, "Tell Steam to 'Verify integrity of game files'." ) ); + steamVerifyIntegrityMenuItem.addActionListener( this ); + helpMenu.add( steamVerifyIntegrityMenuItem ); + helpMenu.add( new JSeparator() ); + aboutMenuItem = new JMenuItem( "About" ); + aboutMenuItem.addMouseListener( new StatusbarMouseListener( this, "Show info about this application." ) ); + aboutMenuItem.addActionListener( this ); + helpMenu.add( aboutMenuItem ); + menubar.add( helpMenu ); + this.setJMenuBar( menubar ); + + this.setGlassPane( new InertPanel() ); + + this.setContentPane( contentPane ); + this.pack(); + this.setMinimumSize( new Dimension( 300, modActionsPanel.getPreferredSize().height+90 ) ); + this.setLocationRelativeTo( null ); + + if ( appConfig.getProperty( SlipstreamConfig.REMEMBER_GEOMETRY ).equals( "true" ) ) + setGeometryFromConfig(); + + showAboutInfo(); + } + + private void setGeometryFromConfig() { + String geometry = appConfig.getProperty( SlipstreamConfig.MANAGER_GEOMETRY ); + if ( geometry != null ) { + int[] xywh = new int[4]; + int dividerLoc = -1; + Matcher m = Pattern.compile( "([^;,]+),(\\d+)" ).matcher( geometry ); + while ( m.find() ) { + if ( m.group( 1 ).equals( "x" ) ) + xywh[0] = Integer.parseInt( m.group( 2 ) ); + else if ( m.group( 1 ).equals( "y" ) ) + xywh[1] = Integer.parseInt( m.group( 2 ) ); + else if ( m.group( 1 ).equals( "w" ) ) + xywh[2] = Integer.parseInt( m.group( 2 ) ); + else if ( m.group( 1 ).equals( "h" ) ) + xywh[3] = Integer.parseInt( m.group( 2 ) ); + else if ( m.group( 1 ).equals( "divider" ) ) + dividerLoc = Integer.parseInt( m.group( 2 ) ); + } + boolean badGeometry = false; + for ( int n : xywh ) { + if ( n <= 0 ) { + badGeometry = true; + break; + } + } + if ( !badGeometry && dividerLoc > 0 ) { + Rectangle newBounds = new Rectangle( xywh[0], xywh[1], xywh[2], xywh[3] ); + ManagerFrame.this.setBounds( newBounds ); + splitPane.setDividerLocation( dividerLoc ); + } + } + } + + /** + * Extra one-time initialization that must be called after the constructor. + */ + public void init() { + if ( ranInit ) return; + ranInit = true; + + previousUncaughtExceptionHandler = Thread.getDefaultUncaughtExceptionHandler(); + Thread.setDefaultUncaughtExceptionHandler( this ); + + ManagerInitThread initThread = new ManagerInitThread( + this, + new SlipstreamConfig( appConfig ), + modsDir, + modsTableStateFile, + metadataFile, + catalogFile, + catalogETagFile, + appUpdateFile, + appUpdateETagFile + ); + initThread.setDaemon( true ); + initThread.setPriority( Thread.MIN_PRIORITY ); + initThread.start(); + } + + + /** + * Returns a ListState describing content in the mods table. + */ + public ListState getCurrentModsTableState() { + ListState tableState = new ListState(); + + for ( ModFileInfo modFileInfo : modsTablePanel.getAllItems() ) { + tableState.addItem( modFileInfo ); + } + + return tableState; + } + + /** + * Synchronizes a mods table state with a pool of available items. + * + * Items in the table that also are in the pool are unchanged. + * Items in the table that aren't in the pool are pruned. + * Items in the pool that weren't in the table are appended in ascending + * order. + * + * @param tableState an existing state to amend + * @param unsortedMods the pool of currently available local mods + */ + public void amendModsTableState( ListState tableState, List unsortedMods ) { + List availableMods = new ArrayList( unsortedMods ); + Collections.sort( availableMods ); + + for ( ModFileInfo modFileInfo : availableMods ) { + if ( !tableState.containsItem( modFileInfo ) ) { + tableState.addItem( modFileInfo ); + } + } + for ( ModFileInfo modFileInfo : tableState.getItems() ) { + if ( !availableMods.contains( modFileInfo ) ) { + tableState.removeItem( modFileInfo ); + } + } + } + + + private void saveModsTableState( ListState tableState ) { + BufferedWriter bw = null; + try { + FileOutputStream os = new FileOutputStream( modsTableStateFile ); + bw = new BufferedWriter(new OutputStreamWriter( os, Charset.forName( "UTF-8" ) )); + + for ( ModFileInfo modFileInfo : tableState.getItems() ) { + bw.write( modFileInfo.getFile().getName() ); + bw.write( "\r\n" ); + } + bw.flush(); + } + catch ( IOException e ) { + log.error( String.format( "Error writing \"%s\"", modsTableStateFile.getName() ), e ); + } + finally { + try {if ( bw != null ) bw.close();} + catch (Exception e) {} + } + } + + + /** + * Clears and syncs the mods list with mods/ dir, then starts a new hash + * thread. + */ + public void rescanMods( ListState tableState ) { + managerLock.lock(); + try { + if ( scanning ) return; + scanning = true; + rescanMenuItem.setEnabled( !scanning ); + } + finally { + managerLock.unlock(); + } + + modFileHashes.clear(); + modsTablePanel.clear(); + + boolean allowZip = appConfig.getProperty( SlipstreamConfig.ALLOW_ZIP, "false" ).equals( "true" ); + File[] modFiles = modsDir.listFiles( new ModFileFilter( allowZip ) ); + + List unsortedMods = new ArrayList(); + for ( File f : modFiles ) { + ModFileInfo modFileInfo = new ModFileInfo( f ); + unsortedMods.add( modFileInfo ); + } + amendModsTableState( tableState, unsortedMods ); + + for ( ModFileInfo modFileInfo : tableState.getItems() ) { + modsTablePanel.getTableModel().addItem( modFileInfo ); + } + + ModsScanThread scanThread = new ModsScanThread( modFiles, localModDB, this ); + scanThread.setDaemon( true ); + scanThread.setPriority( Thread.MIN_PRIORITY ); + scanThread.start(); + } + + + public void showAboutInfo() { + String body = "" + + "- Drag to reorder mods.\n" + + "- Click the checkboxes to select.\n" + + "- Click 'Patch' to apply mods ( select none for vanilla ).\n" + + "\n" + + "Thanks for using this mod manager.\n" + + "Make sure to visit the forum for updates!"; + + infoArea.setDescription( appName, appAuthor, appVersion.toString(), appURL, body ); + } + + public void showAppUpdateInfo() { + StringBuilder buf = new StringBuilder(); + + try { + infoArea.clear(); + infoArea.appendTitleText( "What's New\n" ); + + // Links. + infoArea.appendRegularText( String.format( "Version %s: ", appUpdateInfo.getLatestVersion().toString() ) ); + boolean first = true; + for ( Map.Entry entry : appUpdateInfo.getLatestURLs().entrySet() ) { + if ( !first ) infoArea.appendRegularText( " " ); + infoArea.appendRegularText( "[" ); + infoArea.appendLinkText( entry.getValue(), entry.getKey() ); + infoArea.appendRegularText( "]" ); + first = false; + } + infoArea.appendRegularText( "\n" ); + infoArea.appendRegularText( "\n" ); + + // Notice. + if ( appUpdateInfo.getNotice() != null && appUpdateInfo.getNotice().length() > 0 ) { + infoArea.appendRegularText( appUpdateInfo.getNotice() ); + infoArea.appendRegularText( "\n" ); + infoArea.appendRegularText( "\n" ); + } + + // Changelog. + for ( Map.Entry> entry : appUpdateInfo.getChangelog().entrySet() ) { + if ( appVersion.compareTo( entry.getKey() ) >= 0 ) break; + + if ( buf.length() > 0 ) buf.append( "\n" ); + buf.append( entry.getKey() ).append( ":\n" ); + + for ( String change : entry.getValue() ) { + buf.append( " - " ).append( change ).append( "\n" ); + } + } + infoArea.appendRegularText( buf.toString() ); + + infoArea.setCaretPosition( 0 ); + } + catch ( Exception e ) { + log.error( "Error filling info text area", e ); + } + } + + /** + * Shows info about a local mod in the text area. + * + * Priority is given to embedded metadata.xml, but when that's absent, + * the gatalog's info is used. If the catalog doesn't have the info, + * an 'info missing' notice is shown instead. + */ + public void showLocalModInfo( ModFileInfo modFileInfo ) { + String modHash = modFileHashes.get( modFileInfo.getFile() ); + + ModInfo modInfo = localModDB.getModInfo( modHash ); + if ( modInfo == null || modInfo.isBlank() ) { + modInfo = catalogModDB.getModInfo( modHash ); + } + + if ( modInfo != null ) { + infoArea.setDescription( modInfo.getTitle(), modInfo.getAuthor(), modInfo.getVersion(), modInfo.getURL(), modInfo.getDescription() ); + } + else { + boolean notYetReady = isScanning(); + + if ( notYetReady ) { + String body = "" + + "No info is currently available for the selected mod.\n\n" + + "But Slipstream has not yet finished scanning the mods/ folder. " + + "Try clicking this mod again after waiting a few seconds."; + + infoArea.setDescription( modFileInfo.getName(), body ); + } + else { + Date modDate = modFileDates.get( modHash ); + if ( modDate == null ) { + long epochTime = -1; + try { + epochTime = ModUtilities.getModFileTime( modFileInfo.getFile() ); + } + catch ( IOException e ) { + log.error( String.format( "Error while getting modified time of mod file contents for \"%s\"", modFileInfo.getFile() ), e ); + } + if ( epochTime != -1 ) { + modDate = new Date( epochTime ); + modFileDates.put( modHash, modDate ); + } + } + + StringBuilder bodyBuf = new StringBuilder(); + bodyBuf.append( "No info is available for the selected mod.\n\n" ); + + if ( modDate != null ) { + SimpleDateFormat dateFormat = new SimpleDateFormat( "yyyy-MM-dd" ); + bodyBuf.append( String.format( "It was released some time after %s.\n\n", dateFormat.format( modDate ) ) ); + } + else { + bodyBuf.append( "The date of its release could not be determined.\n\n" ); + } + + bodyBuf.append( "Mods can include an embedded description, but this one did not.\n" ); + + infoArea.setDescription( modFileInfo.getName(), bodyBuf.toString() ); + } + } + } + + public void exitApp() { + this.setVisible( false ); + this.dispose(); + } + + + @Override + public void setStatusText( String text ) { + if ( text.length() > 0 ) + statusLbl.setText( text ); + else + statusLbl.setText( " " ); + } + + + @Override + public void actionPerformed( ActionEvent e ) { + Object source = e.getSource(); + + if ( source == patchBtn ) { + List modFiles = new ArrayList(); + + for ( ModFileInfo modFileInfo : modsTablePanel.getSelectedItems() ) { + modFiles.add( modFileInfo.getFile() ); + } + + File datsDir = new File( appConfig.getProperty( SlipstreamConfig.FTL_DATS_PATH ) ); + + ModPatchDialog patchDlg = new ModPatchDialog( this, true ); + + // Offer to run FTL. + if ( !"true".equals( appConfig.getProperty( SlipstreamConfig.NEVER_RUN_FTL, "false" ) ) ) { + File exeFile = null; + String[] exeArgs = null; + + // Try to run via Steam. + if ( "true".equals( appConfig.getProperty( SlipstreamConfig.RUN_STEAM_FTL, "false" ) ) ) { + + String steamPath = appConfig.getProperty( SlipstreamConfig.STEAM_EXE_PATH ); + if ( steamPath.length() > 0 ) { + exeFile = new File( steamPath ); + + if ( exeFile.exists() ) { + exeArgs = new String[] {"-applaunch", FTLUtilities.STEAM_APPID_FTL}; + } + else { + log.warn( String.format( "%s does not exist: %s", SlipstreamConfig.STEAM_EXE_PATH, exeFile.getAbsolutePath() ) ); + exeFile = null; + } + } + + if ( exeFile == null ) { + log.warn( "Steam executable could not be found, so FTL will be launched directly" ); + } + } + // Try to run directly. + if ( exeFile == null ) { + exeFile = FTLUtilities.findGameExe( datsDir ); + + if ( exeFile != null ) { + exeArgs = new String[0]; + } else { + log.warn( "FTL executable could not be found" ); + } + } + + if ( exeFile != null ) { + patchDlg.setSuccessTask( new SpawnGameTask( exeFile, exeArgs ) ); + } + } + + log.info( "" ); + log.info( "Patching..." ); + log.info( "" ); + ModPatchThread patchThread = new ModPatchThread( modFiles, datsDir, backupDir, false, patchDlg ); + patchThread.start(); + + patchDlg.setVisible( true ); + } + else if ( source == toggleAllBtn ) { + modsTablePanel.toggleAllItemSelection(); + } + else if ( source == validateBtn ) { + StringBuilder resultBuf = new StringBuilder(); + boolean anyInvalid = false; + + for ( ModFileInfo modFileInfo : modsTablePanel.getSelectedItems() ) { + Report validateReport = ModUtilities.validateModFile( modFileInfo.getFile() ); + + ReportFormatter formatter = new ReportFormatter(); + formatter.format( validateReport.messages, resultBuf, 0 ); + resultBuf.append( "\n" ); + + if ( validateReport.outcome == false ) anyInvalid = true; + } + + if ( resultBuf.length() == 0 ) { + resultBuf.append( "No mods were checked." ); + } + else if ( anyInvalid ) { + resultBuf.append( "\n" ); + resultBuf.append( "FTL itself can tolerate lots of XML typos and still run. But malformed XML may " ); + resultBuf.append( "break tools that do proper parsing, and it hinders the development of new " ); + resultBuf.append( "tools.\n" ); + resultBuf.append( "\n" ); + resultBuf.append( "Slipstream will try to parse XML while patching: first strictly, then failing " ); + resultBuf.append( "over to a sloppy parser. The sloppy parser will tolerate similar errors, at the " ); + resultBuf.append( "risk of unforseen behavior, so satisfying the strict parser is advised.\n" ); + } + infoArea.setDescription( "Results", resultBuf.toString() ); + } + else if ( source == modsFolderBtn ) { + try { + if ( Desktop.isDesktopSupported() ) { + Desktop.getDesktop().open( modsDir.getCanonicalFile() ); + } else { + log.error( String.format( "Java cannot open the %s/ folder for you on this OS", modsDir.getName() ) ); + } + } + catch ( IOException f ) { + log.error( "Error opening mods/ folder", f ); + } + } + else if ( source == updateBtn ) { + showAppUpdateInfo(); + } + else if ( source == rescanMenuItem ) { + setStatusText( "" ); + if ( rescanMenuItem.isEnabled() == false ) return; + + ListState tableState = getCurrentModsTableState(); + rescanMods( tableState ); + } + else if ( source == extractDatsMenuItem ) { + setStatusText( "" ); + JFileChooser extractChooser = new JFileChooser(); + extractChooser.setDialogTitle( "Choose a dir to extract into" ); + extractChooser.setFileHidingEnabled( false ); + extractChooser.setFileSelectionMode( JFileChooser.DIRECTORIES_ONLY ); + extractChooser.setMultiSelectionEnabled( false ); + + if ( extractChooser.showSaveDialog( this ) != JFileChooser.APPROVE_OPTION ) + return; + + File extractDir = extractChooser.getSelectedFile(); + + File datsDir = new File( appConfig.getProperty( SlipstreamConfig.FTL_DATS_PATH ) ); + + DatExtractDialog extractDlg = new DatExtractDialog( this, extractDir, datsDir ); + extractDlg.extract(); + extractDlg.setVisible( true ); + } + else if ( source == createModMenuItem ) { + setStatusText( "" ); + + CreateModDialog createModDlg = new CreateModDialog( ManagerFrame.this, modsDir ); + createModDlg.addWindowListener( nerfListener ); + //configDlg.setSize( 300, 400 ); + createModDlg.setLocationRelativeTo( null ); + createModDlg.setVisible( true ); + } + else if ( source == sandboxMenuItem ) { + setStatusText( "" ); + File datsDir = new File( appConfig.getProperty( SlipstreamConfig.FTL_DATS_PATH ) ); + + ModXMLSandbox sandboxFrame = new ModXMLSandbox( datsDir ); + sandboxFrame.addWindowListener( nerfListener ); + sandboxFrame.setSize( 800, 600 ); + sandboxFrame.setLocationRelativeTo( null ); + sandboxFrame.setVisible( true ); + } + else if ( source == configMenuItem ) { + setStatusText( "" ); + + SlipstreamConfigDialog configDlg = new SlipstreamConfigDialog( ManagerFrame.this, appConfig ); + configDlg.addWindowListener( nerfListener ); + //configDlg.setSize( 300, 400 ); + configDlg.setLocationRelativeTo( null ); + configDlg.setVisible( true ); + } + else if ( source == exitMenuItem ) { + setStatusText( "" ); + exitApp(); + } + else if ( source == deleteBackupsMenuItem ) { + String deletePrompt = "" + + "Slipstream uses backups to revert FTL to a state without mods.\n" + + "You are about to delete them.\n" + + "\n" + + "The next time you click 'patch', Slipstream will create fresh backups.\n" + + "\n" + + "FTL *must be* in a working unmodded state *before* you click 'patch'.\n" + + "\n" + + "To get FTL into a working unmodded state, you may need to reinstall FTL\n" + + "or use Steam's \"Verify integrity of game files\" feature.\n" + + "\n" + + "Whenever FTL is updated, you will need to delete stale backups or the\n" + + "game will break.\n" + + "\n" + + "Are you sure you want to continue?"; + + int response = JOptionPane.showConfirmDialog( ManagerFrame.this, deletePrompt, "Continue?", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE ); + if ( response == JOptionPane.YES_OPTION ) { + + List failures = new ArrayList( 2 ); + boolean backupsExist = false; + for ( String datName : new String[] {"ftl.dat", "data.dat", "resource.dat"} ) { + File bakFile = new File( backupDir, datName +".bak" ); + if ( bakFile.exists() ) { + backupsExist = true; + + if ( !bakFile.delete() ) { + log.error( "Unable to delete backup: "+ bakFile.getName() ); + failures.add( bakFile.getName() ); + } + } + } + if ( !backupsExist ) { + JOptionPane.showMessageDialog( ManagerFrame.this, "There were no backups to delete.", "Nothing to do", JOptionPane.INFORMATION_MESSAGE ); + } + else if ( failures.isEmpty() ) { + JOptionPane.showMessageDialog( ManagerFrame.this, "Backups were deleted successfully.", "Success", JOptionPane.INFORMATION_MESSAGE ); + } + else { + StringBuilder failBuf = new StringBuilder( "The following files couldn't be deleted:" ); + for ( String s : failures ) { + failBuf.append( "- \"" ).append( s ).append( "\"\n" ); + } + failBuf.append( "\nTry going in the \"SMM/backup/\" folder and deleting them manually?" ); + JOptionPane.showMessageDialog( ManagerFrame.this, failBuf.toString(), "Error", JOptionPane.ERROR_MESSAGE ); + + try { + if ( Desktop.isDesktopSupported() ) { + Desktop.getDesktop().open( backupDir.getCanonicalFile() ); + } else { + log.error( String.format( "Java cannot open the %s/ folder for you on this OS", backupDir.getName() ) ); + } + } + catch ( IOException f ) { + log.error( String.format( "Error opening %s/ folder", backupDir.getName() ), f ); + } + } + } + } + else if ( source == steamVerifyIntegrityMenuItem ) { + String exePath = appConfig.getProperty( SlipstreamConfig.STEAM_EXE_PATH, "" ); + File exeFile = null; + if ( exePath.length() == 0 || !(exeFile=new File( exePath )).exists() ) { + log.warn( "Steam's location was either not set or doesn't exist" ); + JOptionPane.showMessageDialog( ManagerFrame.this, "Steam's location was either not set or doesn't exist.", "Nothing to do", JOptionPane.WARNING_MESSAGE ); + return; + } + + String verifyPrompt = "" + + "Slipstream is about to tell Steam to re-download FTL's resources. This will get\n" + + "the game back to a working unmodded state, but it could take a while.\n" + + "\n" + + "You can do it manually like this...\n" + + "- Go to Steam's Library.\n" + + "- Right-click FTL, choose \"Properties\".\n" + + "- Click the \"Verify integrity of game files...\" button.\n" + + "\n" + + "If you do not have Steam, you will need to reinstall FTL instead.\n" + + "\n" + + "Either way, you should delete Slipstream's backups as well.\n" + + "\n" + + "Are you sure you want to continue?"; + + int response = JOptionPane.showConfirmDialog( ManagerFrame.this, verifyPrompt, "Continue?", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE ); + if ( response == JOptionPane.YES_OPTION ) { + try { + FTLUtilities.verifySteamGameCache( exeFile, FTLUtilities.STEAM_APPID_FTL ); + } + catch ( IOException f ) { + log.error( "Couldn't tell Steam to 'verify integrity of game files'", f ); + } + } + + } + else if ( source == aboutMenuItem ) { + setStatusText( "" ); + showAboutInfo(); + } + } + + + @Override + public void hashCalculated( final File f, final String hash ) { + Runnable r = new Runnable() { + @Override + public void run() { modFileHashes.put( f, hash ); } + }; + if ( SwingUtilities.isEventDispatchThread() ) r.run(); + else SwingUtilities.invokeLater( r ); + } + + @Override + public void localModDBUpdated( ModDB newDB ) { + setLocalModDB( newDB ); + } + + @Override + public void modsScanEnded() { + Runnable r = new Runnable() { + @Override + public void run() { + managerLock.lock(); + try { + scanning = false; + rescanMenuItem.setEnabled( !scanning ); + scanEndedCond.signalAll(); + } + finally { + managerLock.unlock(); + } + } + }; + if ( SwingUtilities.isEventDispatchThread() ) r.run(); + else SwingUtilities.invokeLater( r ); + } + + + /** + * Returns a lock for synchronizing thread operations. + */ + public Lock getLock() { + return managerLock; + } + + /** + * Returns a condition that will signal when the "mods/" dir has been scanned. + * + * Call getLock().lock() first. + * Loop while isScanning() is true, calling this condition's await(). + * Finally, call getLock().unlock(). + */ + public Condition getScanEndedCondition() { + return scanEndedCond; + } + + /** + * Returns true if the "mods/" folder is currently being scanned. (thread-safe) + */ + public boolean isScanning() { + managerLock.lock(); + try { + return scanning; + } + finally { + managerLock.unlock(); + } + } + + + @Override + public void setNerfed( boolean b ) { + Component glassPane = this.getGlassPane(); + if (b) { + glassPane.setVisible( true ); + glassPane.requestFocusInWindow(); + } else { + glassPane.setVisible( false ); + } + } + + + /** + * Sets the ModDB for local metadata. (thread-safe) + */ + public void setLocalModDB( final ModDB newDB ) { + Runnable r = new Runnable() { + @Override + public void run() { localModDB = newDB; } + }; + if ( SwingUtilities.isEventDispatchThread() ) r.run(); + else SwingUtilities.invokeLater( r ); + } + + /** + * Sets the ModDB for the catalog. + */ + public void setCatalogModDB( ModDB newDB ) { + catalogModDB = newDB; + } + + /** + * Sets info about available app updates. + */ + public void setAppUpdateInfo( AutoUpdateInfo aui ) { + appUpdateInfo = aui; + boolean isUpdateAvailable = ( appVersion.compareTo(appUpdateInfo.getLatestVersion()) < 0 ); + updateBtn.setForeground( isUpdateAvailable ? updateBtnEnabledColor : updateBtnDisabledColor ); + updateBtn.setEnabled( isUpdateAvailable ); + } + + /** + * Toggles whether to perform the usual actions after disposal. + * + * Set this to false before an abnormal exit. + */ + public void setDisposeNormally( boolean b ) { + disposeNormally = b; + } + + @Override + public void uncaughtException( Thread t, Throwable e ) { + log.error( "Uncaught exception in thread: "+ t.toString(), e ); + + final String threadString = t.toString(); + final String errString = e.toString(); + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + String message = "" + + "An unexpected error has occurred.\n" + + "\n" + + "Error: "+ errString +"\n" + + "\n" + + "See the log for details.\n" + + "\n" + + "If this interrupted patching, FTL's resources were probably corrupted.\n" + + "Restart Slipstream and patch without mods to restore vanilla backups."; + + JOptionPane.showMessageDialog( ManagerFrame.this, message, "Error", JOptionPane.ERROR_MESSAGE ); + } + }); + } + + + + private class SpawnGameTask implements Runnable { + private final File exeFile; + private final String[] exeArgs; + + public SpawnGameTask( File exeFile, String... exeArgs ) { + if ( exeArgs == null ) exeArgs = new String[0]; + this.exeFile = exeFile; + this.exeArgs = new String[exeArgs.length]; + System.arraycopy( exeArgs, 0, this.exeArgs, 0, exeArgs.length ); + } + + @Override + public void run() { + if ( exeFile != null ) { + int response = JOptionPane.showConfirmDialog( ManagerFrame.this, "Do you want to run the game now?", "Ready to Play", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE ); + if ( response == JOptionPane.YES_OPTION ) { + log.info( "Running FTL..." ); + try { + FTLUtilities.launchExe( exeFile, exeArgs ); + } + catch ( Exception e ) { + log.error( "Error launching FTL", e ); + } + exitApp(); + } + } + } + } + + + + /** + * Toggles a main window's nerfed state as popups are opened/disposed. + * + * Requires: setDefaultCloseOperation( JFrame.DISPOSE_ON_CLOSE ). + */ + private static class NerfListener extends WindowAdapter { + private Nerfable nerfObj; + + public NerfListener( Nerfable nerfObj ) { + this.nerfObj = nerfObj; + } + + @Override + public void windowOpened( WindowEvent e ) { + nerfObj.setNerfed( true ); + } + @Override + public void windowClosed( WindowEvent e ) { + nerfObj.setNerfed( false ); + } + } + + + + private static class ModFileFilter implements FileFilter { + boolean allowZip; + + public ModFileFilter( boolean allowZip ) { + this.allowZip = allowZip; + } + + @Override + public boolean accept( File f ) { + if ( f.isFile() ) { + if ( f.getName().endsWith( ".ftl" ) ) return true; + + if ( allowZip ) { + if ( f.getName().endsWith( ".zip" ) ) return true; + } + } + return false; + } + } +} diff --git a/src/main/java/net/vhati/modmanager/ui/ManagerInitThread.java b/src/main/java/net/vhati/modmanager/ui/ManagerInitThread.java new file mode 100644 index 0000000..7ad4762 --- /dev/null +++ b/src/main/java/net/vhati/modmanager/ui/ManagerInitThread.java @@ -0,0 +1,243 @@ +package net.vhati.modmanager.ui; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.List; +import java.util.concurrent.locks.Lock; +import javax.swing.SwingUtilities; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import net.vhati.modmanager.core.AutoUpdateInfo; +import net.vhati.modmanager.core.ModDB; +import net.vhati.modmanager.core.ModFileInfo; +import net.vhati.modmanager.core.SlipstreamConfig; +import net.vhati.modmanager.json.JacksonAutoUpdateReader; +import net.vhati.modmanager.json.JacksonCatalogReader; +import net.vhati.modmanager.json.URLFetcher; +import net.vhati.modmanager.ui.ManagerFrame; +import net.vhati.modmanager.ui.table.ListState; + + +/** + * Performs I/O-related setup for ManagerFrame in the background. + * + * Reads cached local metadata. + * Rescans the "mods/" folder. + * Reads saved catalog, and redownloads if stale. + * Reads saved info about app updates, and redownloads if stale. + */ +public class ManagerInitThread extends Thread { + + private static final Logger log = LoggerFactory.getLogger( ManagerInitThread.class ); + + private final ManagerFrame frame; + private final SlipstreamConfig appConfig; + private final File modsDir; + private final File modsTableStateFile; + private final File metadataFile; + private final File catalogFile; + private final File catalogETagFile; + private final File appUpdateFile; + private final File appUpdateETagFile; + + + public ManagerInitThread( ManagerFrame frame, SlipstreamConfig appConfig, File modsDir, File modsTableStateFile, File metadataFile, File catalogFile, File catalogETagFile, File appUpdateFile, File appUpdateETagFile ) { + super( "init" ); + this.frame = frame; + this.appConfig = appConfig; + this.modsDir = modsDir; + this.modsTableStateFile = modsTableStateFile; + this.metadataFile = metadataFile; + this.catalogFile = catalogFile; + this.catalogETagFile = catalogETagFile; + this.appUpdateFile = appUpdateFile; + this.appUpdateETagFile = appUpdateETagFile; + } + + + @Override + public void run() { + try { + init(); + } + catch ( Exception e ) { + log.error( "Error during ManagerFrame init.", e ); + } + } + + + private void init() throws InterruptedException { + + if ( metadataFile.exists() ) { + // Load cached metadata first, before scanning for new info. + ModDB cachedDB = JacksonCatalogReader.parse( metadataFile ); + if ( cachedDB != null ) frame.setLocalModDB( cachedDB ); + } + + final ListState tableState = loadModsTableState(); + + Lock managerLock = frame.getLock(); + managerLock.lock(); + try { + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { frame.rescanMods( tableState ); } + }); + + // Wait until notified that "mods/" has been scanned. + while ( frame.isScanning() ) { + frame.getScanEndedCondition().await(); + } + } + finally { + managerLock.unlock(); + } + + int catalogUpdateInterval = appConfig.getPropertyAsInt( "update_catalog", 0 ); + boolean needNewCatalog = false; + + // Load the catalog first, before downloading. + if ( catalogFile.exists() ) reloadCatalog(); + + if ( catalogUpdateInterval > 0 ) { + if ( catalogFile.exists() ) { + // Check if the downloaded catalog is stale. + if ( isFileStale( catalogFile, catalogUpdateInterval ) ) { + log.debug( String.format( "Catalog is older than %d days", catalogUpdateInterval ) ); + needNewCatalog = true; + } else { + log.debug( "Catalog isn't stale yet" ); + } + } + else { + // Catalog file doesn't exist. + needNewCatalog = true; + } + } + + if ( needNewCatalog ) { + boolean fetched = URLFetcher.refetchURL( ManagerFrame.CATALOG_URL, catalogFile, catalogETagFile ); + if ( fetched && catalogFile.exists() ) { + reloadCatalog(); + } + } + + // Load the cached info first, before downloading. + if ( appUpdateFile.exists() ) reloadAppUpdateInfo(); + + int appUpdateInterval = appConfig.getPropertyAsInt( SlipstreamConfig.UPDATE_APP, 0 ); + boolean needAppUpdate = false; + + if ( appUpdateInterval > 0 ) { + if ( appUpdateFile.exists() ) { + // Check if the app update info is stale. + if ( isFileStale( appUpdateFile, appUpdateInterval ) ) { + log.debug( String.format( "App update info is older than %d days", appUpdateInterval ) ); + needAppUpdate = true; + } else { + log.debug( "App update info isn't stale yet" ); + } + } + else { + // App update file doesn't exist. + needAppUpdate = true; + } + } + + if ( needAppUpdate ) { + boolean fetched = URLFetcher.refetchURL( ManagerFrame.APP_UPDATE_URL, appUpdateFile, appUpdateETagFile ); + if ( fetched && appUpdateFile.exists() ) { + reloadAppUpdateInfo(); + } + } + } + + + /** + * Reads modorder.txt and returns a list of mod names in preferred order. + */ + private ListState loadModsTableState() { + List fileNames = new ArrayList(); + + BufferedReader br = null; + try { + FileInputStream is = new FileInputStream( modsTableStateFile ); + br = new BufferedReader( new InputStreamReader( is, Charset.forName( "UTF-8" ) ) ); + String line; + while ( (line = br.readLine()) != null ) { + fileNames.add( line ); + } + } + catch ( FileNotFoundException e ) { + } + catch ( IOException e ) { + log.error( String.format( "Error reading \"%s\"", modsTableStateFile.getName() ), e ); + fileNames.clear(); + } + finally { + try {if ( br != null ) br.close();} + catch ( Exception e ) {} + } + + ListState result = new ListState(); + + for ( String fileName : fileNames ) { + File modFile = new File( modsDir, fileName ); + ModFileInfo modFileInfo = new ModFileInfo( modFile ); + result.addItem( modFileInfo ); + } + + return result; + } + + + private void reloadCatalog() { + final ModDB currentDB = JacksonCatalogReader.parse( catalogFile ); + if ( currentDB != null ) { + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + frame.setCatalogModDB( currentDB ); + } + }); + } + } + + private void reloadAppUpdateInfo() { + final AutoUpdateInfo aui = JacksonAutoUpdateReader.parse( appUpdateFile ); + if ( aui != null ) { + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + frame.setAppUpdateInfo( aui ); + } + }); + } + } + + + /** + * Returns true if a file is older than N days. + */ + private boolean isFileStale( File f, int maxDays ) { + Calendar fileCal = Calendar.getInstance(); + fileCal.setTimeInMillis( f.lastModified() ); + fileCal.getTimeInMillis(); // Re-calculate calendar fields. + + Calendar freshCal = Calendar.getInstance(); + freshCal.add( Calendar.DATE, maxDays * -1 ); + freshCal.getTimeInMillis(); // Re-calculate calendar fields. + + return (fileCal.compareTo( freshCal ) < 0); + } +} diff --git a/src/main/java/net/vhati/modmanager/ui/ModInfoArea.java b/src/main/java/net/vhati/modmanager/ui/ModInfoArea.java new file mode 100644 index 0000000..f7dfae7 --- /dev/null +++ b/src/main/java/net/vhati/modmanager/ui/ModInfoArea.java @@ -0,0 +1,277 @@ +package net.vhati.modmanager.ui; + +import java.awt.Color; +import java.awt.Cursor; +import java.awt.Desktop; +import java.awt.Font; +import java.awt.Toolkit; +import java.awt.datatransfer.StringSelection; +import java.awt.event.ActionEvent; +import java.awt.event.MouseEvent; +import java.net.URI; +import javax.swing.AbstractAction; +import javax.swing.JPopupMenu; +import javax.swing.JScrollPane; +import javax.swing.JTextPane; +import javax.swing.SwingUtilities; +import javax.swing.event.MouseInputAdapter; +import javax.swing.text.AttributeSet; +import javax.swing.text.BadLocationException; +import javax.swing.text.DefaultStyledDocument; +import javax.swing.text.SimpleAttributeSet; +import javax.swing.text.Style; +import javax.swing.text.StyleConstants; +import javax.swing.text.StyleContext; +import javax.swing.text.StyledDocument; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import net.vhati.modmanager.ui.Statusbar; + + +public class ModInfoArea extends JScrollPane { + + private static final Logger log = LoggerFactory.getLogger( ModInfoArea.class ); + + private static final String STYLE_REGULAR = "regular"; + private static final String STYLE_HYPERLINK = "hyperlink"; + private static final String STYLE_TITLE = "title"; + private static final String ATTR_HYPERLINK_TARGET = "hyperlink-target"; + + public static Color COLOR_HYPER = Color.BLUE; + public static final StyleContext DEFAULT_STYLES = ModInfoArea.getDefaultStyleContext(); + + private JPopupMenu linkPopup = new JPopupMenu(); + + private Statusbar statusbar = null; + private String lastClickedLinkTarget = null; + + private JTextPane textPane; + private StyledDocument doc; + private boolean browseWorks; + + + public ModInfoArea() { + this( DEFAULT_STYLES ); + } + + public ModInfoArea( StyleContext styleContext ) { + super(); + + textPane = new JTextPane(); + textPane.setEditable( false ); + + doc = new DefaultStyledDocument( styleContext ); + textPane.setStyledDocument( doc ); + + Desktop desktop = Desktop.isDesktopSupported() ? Desktop.getDesktop() : null; + if ( desktop != null && desktop.isSupported( Desktop.Action.BROWSE ) ) { + browseWorks = true; + } + + linkPopup.add( new AbstractAction( "Copy link address" ) { + @Override + public void actionPerformed( ActionEvent ae ) { + if ( lastClickedLinkTarget != null ) { + Toolkit.getDefaultToolkit().getSystemClipboard().setContents( new StringSelection( lastClickedLinkTarget ), null ); + } + } + }); + + MouseInputAdapter hyperlinkListener = new MouseInputAdapter() { + private Cursor defaultCursor = new Cursor( Cursor.DEFAULT_CURSOR ); + private Cursor linkCursor = new Cursor( Cursor.HAND_CURSOR ); + private boolean wasOverLink = false; + + @Override + public void mousePressed( MouseEvent e ) { + if ( e.isConsumed() ) return; + if ( e.isPopupTrigger() ) showMenu( e ); + } + + @Override + public void mouseReleased( MouseEvent e ) { + if ( e.isConsumed() ) return; + if ( e.isPopupTrigger() ) showMenu( e ); + } + + @Override + public void mouseClicked( MouseEvent e ) { + if ( e.isConsumed() ) return; + if ( !SwingUtilities.isLeftMouseButton( e ) ) return; + + AttributeSet tmpAttr = doc.getCharacterElement( textPane.viewToModel( e.getPoint() ) ).getAttributes(); + Object targetObj = tmpAttr.getAttribute( ATTR_HYPERLINK_TARGET ); + if ( targetObj != null ) { + Desktop desktop = Desktop.isDesktopSupported() ? Desktop.getDesktop() : null; + if ( desktop != null && desktop.isSupported( Desktop.Action.BROWSE ) ) { + try { + desktop.browse( new URI( targetObj.toString() ) ); + } + catch ( Exception f ) { + log.error( "Error browsing clicked url: "+ targetObj.toString(), f ); + } + } + e.consume(); + } + } + + @Override + public void mouseMoved( MouseEvent e ) { + AttributeSet tmpAttr = doc.getCharacterElement( textPane.viewToModel( e.getPoint() ) ).getAttributes(); + Object targetObj = tmpAttr.getAttribute( ATTR_HYPERLINK_TARGET ); + if ( targetObj != null ) { + textPane.setCursor( linkCursor ); + if ( statusbar != null ) + statusbar.setStatusText( targetObj.toString() ); + wasOverLink = true; + } + else { + if ( wasOverLink ) { + textPane.setCursor( defaultCursor ); + if ( statusbar != null ) + statusbar.setStatusText( "" ); + } + wasOverLink = false; + } + } + + private void showMenu( MouseEvent e ) { + AttributeSet tmpAttr = doc.getCharacterElement( textPane.viewToModel( e.getPoint() ) ).getAttributes(); + Object targetObj = tmpAttr.getAttribute( ATTR_HYPERLINK_TARGET ); + if ( targetObj != null ) { // Link menu. + textPane.requestFocus(); + + lastClickedLinkTarget = targetObj.toString(); + + int nx = e.getX(); + if ( nx > 500 ) nx = nx - linkPopup.getSize().width; + + linkPopup.show( e.getComponent(), nx, e.getY() - linkPopup.getSize().height ); + + e.consume(); + } + } + }; + textPane.addMouseListener( hyperlinkListener ); + textPane.addMouseMotionListener( hyperlinkListener ); + + textPane.addMouseListener( new ClipboardMenuMouseListener() ); + + this.setViewportView( textPane ); + } + + + public void setDescription( String title, String body ) { + setDescription( title, null, null, null, body ); + } + + public void setDescription( String title, String author, String version, String url, String body ) { + Style regularStyle = doc.getStyle( STYLE_REGULAR ); + try { + doc.remove( 0, doc.getLength() ); + doc.insertString( doc.getLength(), title +"\n", doc.getStyle( STYLE_TITLE ) ); + + boolean first = true; + if ( author != null ) { + doc.insertString( doc.getLength(), String.format( "%sby %s", (first ? "" : " "), author ), regularStyle ); + first = false; + } + if ( version != null ) { + doc.insertString( doc.getLength(), String.format( "%s(version %s)", (first ? "" : " "), version ), regularStyle ); + first = false; + } + if ( !first ) { + doc.insertString( doc.getLength(), "\n", regularStyle ); + } + + if ( url != null ) { + doc.insertString( doc.getLength(), "Website: ", regularStyle ); + + if ( browseWorks && url.matches( "^(?:https?|ftp)://.*" ) ) { + SimpleAttributeSet tmpAttr = new SimpleAttributeSet( doc.getStyle( STYLE_HYPERLINK ) ); + tmpAttr.addAttribute( ATTR_HYPERLINK_TARGET, url ); + doc.insertString( doc.getLength(), "Link", tmpAttr ); + } else { + doc.insertString( doc.getLength(), url, regularStyle ); + } + + doc.insertString( doc.getLength(), "\n", regularStyle ); + } + + doc.insertString( doc.getLength(), "\n", regularStyle ); + + if ( body != null ) { + doc.insertString( doc.getLength(), body, regularStyle ); + } + } + catch ( BadLocationException e ) { + log.error( "Error filling info text area", e ); + } + + textPane.setCaretPosition( 0 ); + } + + + public void setCaretPosition( int n ) { + textPane.setCaretPosition( n ); + } + + public void clear() { + try { + doc.remove( 0, doc.getLength() ); + } + catch ( BadLocationException e ) { + log.error( "Error clearing info text area", e ); + } + } + + public void appendTitleText( String s ) throws BadLocationException { + doc.insertString( doc.getLength(), s, doc.getStyle( STYLE_TITLE ) ); + } + + public void appendRegularText( String s ) throws BadLocationException { + doc.insertString( doc.getLength(), s, doc.getStyle( STYLE_REGULAR ) ); + } + + public void appendLinkText( String linkURL, String linkTitle ) throws BadLocationException { + if ( browseWorks && linkURL.matches( "^(?:https?|ftp)://.*" ) ) { + SimpleAttributeSet tmpAttr = new SimpleAttributeSet( doc.getStyle( STYLE_HYPERLINK ) ); + tmpAttr.addAttribute( ATTR_HYPERLINK_TARGET, linkURL ); + doc.insertString( doc.getLength(), linkTitle, tmpAttr ); + } else { + doc.insertString( doc.getLength(), linkURL, doc.getStyle( STYLE_REGULAR ) ); + } + } + + + /** + * Sets a component with a statusbar to be set during mouse events. + */ + public void setStatusbar( Statusbar comp ) { + this.statusbar = comp; + } + + + private static StyleContext getDefaultStyleContext() { + StyleContext result = new StyleContext(); + Style defaultStyle = StyleContext.getDefaultStyleContext().getStyle( StyleContext.DEFAULT_STYLE ); + Style baseStyle = result.addStyle( "base", defaultStyle ); + + Style regularStyle = result.addStyle( STYLE_REGULAR, baseStyle ); + StyleConstants.setFontFamily( regularStyle, Font.MONOSPACED ); + StyleConstants.setFontSize( regularStyle, 12 ); + + Style hyperStyle = result.addStyle( STYLE_HYPERLINK, regularStyle ); + StyleConstants.setForeground( hyperStyle, COLOR_HYPER ); + StyleConstants.setUnderline( hyperStyle, true ); + + Style titleStyle = result.addStyle( STYLE_TITLE, baseStyle ); + StyleConstants.setFontFamily( titleStyle, Font.SANS_SERIF ); + StyleConstants.setFontSize( titleStyle, 24 ); + StyleConstants.setBold( titleStyle, true ); + + return result; + } +} diff --git a/src/main/java/net/vhati/modmanager/ui/ModPatchDialog.java b/src/main/java/net/vhati/modmanager/ui/ModPatchDialog.java new file mode 100644 index 0000000..bfa7932 --- /dev/null +++ b/src/main/java/net/vhati/modmanager/ui/ModPatchDialog.java @@ -0,0 +1,78 @@ +package net.vhati.modmanager.ui; + +import java.awt.Frame; +import java.io.File; +import javax.swing.JDialog; +import javax.swing.SwingUtilities; + +import net.vhati.modmanager.core.ModPatchObserver; +import net.vhati.modmanager.ui.ProgressDialog; + + +public class ModPatchDialog extends ProgressDialog implements ModPatchObserver { + + + public ModPatchDialog( Frame owner, boolean continueOnSuccess ) { + super( owner, continueOnSuccess ); + this.setTitle( "Patching..." ); + + this.setSize( 400, 160 ); + this.setMinimumSize( this.getPreferredSize() ); + this.setLocationRelativeTo( owner ); + } + + + /** + * Updates the progress bar. + * + * If either arg is -1, the bar will become indeterminate. + * + * @param value the new value + * @param max the new maximum + */ + @Override + public void patchingProgress( final int value, final int max ) { + this.setProgressLater( value, max ); + } + + /** + * Non-specific activity. + * + * @param message a string, or null + */ + @Override + public void patchingStatus( final String message ) { + setStatusTextLater( message != null ? message : "..." ); + } + + /** + * A mod is about to be processed. + */ + @Override + public void patchingMod( final File modFile ) { + setStatusTextLater( String.format( "Installing mod \"%s\"...", modFile.getName() ) ); + } + + /** + * Patching ended. + * + * If anything went wrong, e may be non-null. + */ + @Override + public void patchingEnded( boolean outcome, Exception e ) { + setTaskOutcomeLater( outcome, e ); + } + + + @Override + protected void setTaskOutcome( boolean outcome, Exception e ) { + super.setTaskOutcome( outcome, e ); + if ( !this.isShowing() ) return; + + if ( succeeded == true ) { + setStatusText( "Patching completed." ); + } else { + setStatusText( String.format( "Patching failed: %s", e ) ); + } + } +} diff --git a/src/main/java/net/vhati/modmanager/ui/ModXMLSandbox.java b/src/main/java/net/vhati/modmanager/ui/ModXMLSandbox.java new file mode 100644 index 0000000..401ead3 --- /dev/null +++ b/src/main/java/net/vhati/modmanager/ui/ModXMLSandbox.java @@ -0,0 +1,591 @@ +package net.vhati.modmanager.ui; + +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.FocusAdapter; +import java.awt.event.FocusEvent; +import java.awt.event.InputEvent; +import java.awt.event.KeyEvent; +import java.io.File; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.io.IOException; +import java.io.FileNotFoundException; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.Iterator; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.swing.AbstractAction; +import javax.swing.BorderFactory; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JComponent; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JOptionPane; +import javax.swing.JScrollPane; +import javax.swing.JSplitPane; +import javax.swing.JTabbedPane; +import javax.swing.JTextArea; +import javax.swing.JTextField; +import javax.swing.JTree; +import javax.swing.KeyStroke; +import javax.swing.SwingUtilities; +import javax.swing.event.AncestorEvent; +import javax.swing.event.AncestorListener; +import javax.swing.event.CaretEvent; +import javax.swing.event.CaretListener; +import javax.swing.event.UndoableEditEvent; +import javax.swing.event.UndoableEditListener; +import javax.swing.text.BadLocationException; +import javax.swing.text.Caret; +import javax.swing.tree.DefaultMutableTreeNode; +import javax.swing.tree.DefaultTreeModel; +import javax.swing.tree.TreePath; +import javax.swing.undo.CannotRedoException; +import javax.swing.undo.UndoManager; + +import net.vhati.ftldat.AbstractPack; +import net.vhati.ftldat.FTLPack; +import net.vhati.ftldat.PkgPack; +import net.vhati.modmanager.core.ModUtilities; +import net.vhati.modmanager.core.SloppyXMLOutputProcessor; +import net.vhati.modmanager.core.XMLPatcher; +import net.vhati.modmanager.ui.ClipboardMenuMouseListener; + +import org.jdom2.JDOMException; + + +/** + * A basic text editor to test XML modding. + */ +public class ModXMLSandbox extends JFrame implements ActionListener { + + private static final String baseTitle = "Mod XML Sandbox"; + + private UndoManager undoManager = new UndoManager(); + private String mainText = null; + + private File datsDir; + + private JTabbedPane areasPane; + private JScrollPane mainScroll; + private JScrollPane appendScroll; + private JScrollPane resultScroll; + private JSplitPane splitPane; + private JScrollPane messageScroll; + + private JTextArea mainArea; + private JTextArea appendArea; + private JTextArea resultArea; + private JTextArea messageArea; + private JTextField findField; + private JButton openBtn; + private JButton patchBtn; + private JLabel statusLbl; + + + public ModXMLSandbox( File datsDir ) { + super( baseTitle ); + this.setDefaultCloseOperation( JFrame.DISPOSE_ON_CLOSE ); + + this.datsDir = datsDir; + + Font sandboxFont = new Font( Font.MONOSPACED, Font.PLAIN, 13 ); + + mainArea = new JTextArea(); + mainArea.setTabSize( 4 ); + mainArea.setFont( sandboxFont ); + mainArea.setEditable( false ); + mainArea.addMouseListener( new ClipboardMenuMouseListener() ); + mainScroll = new JScrollPane( mainArea ); + + appendArea = new JTextArea(); + appendArea.setTabSize( 4 ); + appendArea.setFont( sandboxFont ); + appendArea.addMouseListener( new ClipboardMenuMouseListener() ); + appendScroll = new JScrollPane( appendArea ); + + resultArea = new JTextArea(); + resultArea.setTabSize( 4 ); + resultArea.setFont( sandboxFont ); + resultArea.setEditable( false ); + resultArea.addMouseListener( new ClipboardMenuMouseListener() ); + resultScroll = new JScrollPane( resultArea ); + + messageArea = new JTextArea(); + messageArea.setLineWrap( true ); + messageArea.setWrapStyleWord( true ); + messageArea.setTabSize( 4 ); + messageArea.setFont( sandboxFont ); + messageArea.setEditable( false ); + messageArea.addMouseListener( new ClipboardMenuMouseListener() ); + messageArea.setText( "This is a sandbox to tinker with advanced mod syntax.\n1) Open XML from data.dat to fill the 'main' tab. (ctrl-o)\n2) Write some tags in the 'append' tab. (alt-1,2,3)\n3) Click Patch to see what would happen. (ctrl-p)\nUndo/redo is available. (ctrl-z/ctrl-y)" ); + messageScroll = new JScrollPane( messageArea ); + + JPanel ctrlPanel = new JPanel(); + ctrlPanel.setLayout( new BoxLayout( ctrlPanel, BoxLayout.X_AXIS ) ); + + openBtn = new JButton( "Open Main..." ); + openBtn.addActionListener( this ); + ctrlPanel.add( openBtn ); + + ctrlPanel.add( Box.createHorizontalGlue() ); + + findField = new JTextField( "", 20 ); + findField.setMaximumSize( new Dimension( 60, findField.getPreferredSize().height ) ); + ctrlPanel.add( findField ); + + ctrlPanel.add( Box.createHorizontalGlue() ); + + patchBtn = new JButton( "Patch" ); + patchBtn.addActionListener( this ); + ctrlPanel.add( patchBtn ); + + areasPane = new JTabbedPane( JTabbedPane.BOTTOM ); + areasPane.add( "Main", mainScroll ); + areasPane.add( "Append", appendScroll ); + areasPane.add( "Result", resultScroll ); + + JPanel topPanel = new JPanel( new BorderLayout() ); + topPanel.add( areasPane, BorderLayout.CENTER ); + topPanel.add( ctrlPanel, BorderLayout.SOUTH ); + + splitPane = new JSplitPane( JSplitPane.VERTICAL_SPLIT ); + splitPane.setTopComponent( topPanel ); + splitPane.setBottomComponent( messageScroll ); + + JPanel statusPanel = new JPanel(); + statusPanel.setLayout( new BoxLayout( statusPanel, BoxLayout.Y_AXIS ) ); + statusPanel.setBorder( BorderFactory.createLoweredBevelBorder() ); + statusLbl = new JLabel( " " ); + statusLbl.setBorder( BorderFactory.createEmptyBorder( 2, 4, 2, 4 ) ); + statusLbl.setAlignmentX( Component.LEFT_ALIGNMENT ); + statusPanel.add( statusLbl ); + + JPanel contentPane = new JPanel( new BorderLayout() ); + contentPane.add( splitPane, BorderLayout.CENTER ); + contentPane.add( statusPanel, BorderLayout.SOUTH ); + this.setContentPane( contentPane ); + + findField.addFocusListener(new FocusAdapter() { + @Override + public void focusGained( FocusEvent e ) { + findField.selectAll(); + } + }); + CaretListener caretListener = new CaretListener() { + @Override + public void caretUpdate( CaretEvent e ) { + JTextArea currentArea = getCurrentArea(); + if ( currentArea == null ) return; + if ( e.getSource() != currentArea ) return; + updateCaretStatus(); + } + }; + mainArea.addCaretListener( caretListener ); + appendArea.addCaretListener( caretListener ); + resultArea.addCaretListener( caretListener ); + + CaretAncestorListener caretAncestorListener = new CaretAncestorListener(); + mainArea.addAncestorListener( caretAncestorListener ); + appendArea.addAncestorListener( caretAncestorListener ); + resultArea.addAncestorListener( caretAncestorListener ); + + appendArea.getDocument().addUndoableEditListener(new UndoableEditListener() { + @Override + public void undoableEditHappened( UndoableEditEvent e ) { + undoManager.addEdit( e.getEdit() ); + } + }); + AbstractAction undoAction = new AbstractAction( "Undo" ) { + @Override + public void actionPerformed( ActionEvent e ) { + try {undoManager.undo();} + catch ( CannotRedoException f ) {} + } + }; + AbstractAction redoAction = new AbstractAction( "Redo" ) { + @Override + public void actionPerformed( ActionEvent e ) { + try {undoManager.redo();} + catch ( CannotRedoException f ) {} + } + }; + + AbstractAction openAction = new AbstractAction( "Open" ) { + @Override + public void actionPerformed( ActionEvent e ) { + open(); + } + }; + AbstractAction patchAction = new AbstractAction( "Patch" ) { + @Override + public void actionPerformed( ActionEvent e ) { + patch(); + } + }; + AbstractAction focusFindAction = new AbstractAction( "Focus Find" ) { + @Override + public void actionPerformed( ActionEvent e ) { + findField.requestFocusInWindow(); + } + }; + AbstractAction findNextAction = new AbstractAction( "Find Next" ) { + @Override + public void actionPerformed( ActionEvent e ) { + findNext(); + } + }; + AbstractAction findPreviousAction = new AbstractAction( "Find Previous" ) { + @Override + public void actionPerformed( ActionEvent e ) { + findPrevious(); + } + }; + + KeyStroke undoShortcut = KeyStroke.getKeyStroke( "control Z" ); + appendArea.getInputMap().put( undoShortcut, "undo" ); + appendArea.getActionMap().put( "undo", undoAction ); + KeyStroke redoShortcut = KeyStroke.getKeyStroke( "control Y" ); + appendArea.getInputMap().put( redoShortcut, "redo" ); + appendArea.getActionMap().put( "redo", redoAction ); + + KeyStroke openShortcut = KeyStroke.getKeyStroke( "control O" ); + contentPane.getInputMap( JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT ).put( openShortcut, "open" ); + contentPane.getActionMap().put( "open", openAction ); + KeyStroke patchShortcut = KeyStroke.getKeyStroke( "control P" ); + contentPane.getInputMap( JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT ).put( patchShortcut, "patch" ); + contentPane.getActionMap().put( "patch", patchAction ); + KeyStroke focusFindShortcut = KeyStroke.getKeyStroke( "control F" ); + contentPane.getInputMap( JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT ).put( focusFindShortcut, "focus find" ); + contentPane.getActionMap().put( "focus find", focusFindAction ); + KeyStroke findNextShortcut = KeyStroke.getKeyStroke( "F3" ); + contentPane.getInputMap( JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT ).put( findNextShortcut, "find next" ); + contentPane.getActionMap().put( "find next", findNextAction ); + KeyStroke findPreviousShortcut = KeyStroke.getKeyStroke( "shift F3" ); + contentPane.getInputMap( JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT ).put( findPreviousShortcut, "find previous" ); + contentPane.getActionMap().put( "find previous", findPreviousAction ); + + findField.getInputMap().put( KeyStroke.getKeyStroke( "released ENTER" ), "find next" ); + findField.getActionMap().put( "find next", findNextAction ); + + areasPane.setMnemonicAt( 0, KeyEvent.VK_1 ); + areasPane.setMnemonicAt( 1, KeyEvent.VK_2 ); + areasPane.setMnemonicAt( 2, KeyEvent.VK_3 ); + mainArea.addAncestorListener( new FocusAncestorListener( mainArea ) ); + appendArea.addAncestorListener( new FocusAncestorListener( appendArea ) ); + resultArea.addAncestorListener( new FocusAncestorListener( resultArea ) ); + + this.pack(); + } + + @Override + public void setVisible( boolean b ) { + super.setVisible( b ); + + if ( b ) { + // Splitpane has to be realized before the divider can be moved. + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + splitPane.setDividerLocation( 0.80d ); + } + }); + } + } + + @Override + public void actionPerformed( ActionEvent e ) { + Object source = e.getSource(); + + if ( source == openBtn ) { + open(); + } + else if ( source == patchBtn ) { + patch(); + } + } + + private void open() { + messageArea.setText( "" ); + + AbstractPack pack = null; + InputStream is = null; + try { + File ftlDatFile = new File( datsDir, "ftl.dat" ); + File dataDatFile = new File( datsDir, "data.dat" ); + + if ( ftlDatFile.exists() ) { // FTL 1.6.1. + pack = new PkgPack( ftlDatFile, "r" ); + } + else if ( dataDatFile.exists() ) { // FTL 1.01-1.5.13. + pack = new FTLPack( dataDatFile, "r" ); + } + else { + throw new FileNotFoundException( String.format( "Could not find either \"%s\" or \"%s\"", ftlDatFile.getName(), dataDatFile.getName() ) ); + } + + List innerPaths = pack.list(); + + String innerPath = promptForInnerPath( innerPaths ); + if ( innerPath == null ) return; + + is = pack.getInputStream( innerPath ); + InputStream rebuiltStream = ModUtilities.rebuildXMLFile( is, "windows-1252", pack.getName()+":"+innerPath ); + String rebuiltText = ModUtilities.decodeText( rebuiltStream, "Sandbox Main XML" ).text; + is.close(); + + mainArea.setText( rebuiltText ); + mainArea.setCaretPosition( 0 ); + areasPane.setSelectedComponent( mainScroll ); + resultArea.setText( "" ); + this.setTitle( String.format( "%s - %s", innerPath, baseTitle ) ); + } + catch ( IOException f ) { + messageArea.setText( f.getMessage() ); + messageArea.setCaretPosition( 0 ); + } + catch ( JDOMException f ) { + messageArea.setText( f.getMessage() ); + messageArea.setCaretPosition( 0 ); + } + finally { + try {if ( is != null ) is.close();} + catch ( IOException f ) {} + + try {if ( pack != null ) pack.close();} + catch ( IOException f ) {} + } + } + + private void patch() { + String mainText = mainArea.getText(); + if ( mainText.length() == 0 ) return; + + messageArea.setText( "" ); + + try { + InputStream mainStream = new ByteArrayInputStream( mainText.getBytes( "UTF-8" ) ); + + String appendText = appendArea.getText(); + InputStream appendStream = new ByteArrayInputStream( appendText.getBytes( "UTF-8" ) ); + + InputStream resultStream = ModUtilities.patchXMLFile( mainStream, appendStream, "windows-1252", false, "Sandbox Main XML", "Sandbox Append XML" ); + String resultText = ModUtilities.decodeText( resultStream, "Sandbox Result XML" ).text; + + resultArea.setText( resultText ); + resultArea.setCaretPosition( 0 ); + areasPane.setSelectedComponent( resultScroll ); + } + catch ( Exception e ) { + messageArea.setText( e.toString() ); + messageArea.setCaretPosition( 0 ); + } + } + + private void findNext() { + JTextArea currentArea = getCurrentArea(); + if ( currentArea == null ) return; + + String query = findField.getText(); + if ( query.length() == 0 ) return; + + Caret caret = currentArea.getCaret(); + int from = Math.max( caret.getDot(), caret.getMark() ); + + Pattern ptn = Pattern.compile( "(?i)"+ Pattern.quote( query ) ); + Matcher m = ptn.matcher( currentArea.getText() ); + if ( m.find(from) ) { + caret.setDot( m.start() ); + caret.moveDot( m.end() ); + caret.setSelectionVisible( true ); + } + } + + private void findPrevious() { + JTextArea currentArea = getCurrentArea(); + if ( currentArea == null ) return; + + String query = findField.getText(); + if ( query.length() == 0 ) return; + + Caret caret = currentArea.getCaret(); + int from = Math.min( caret.getDot(), caret.getMark() ); + + Pattern ptn = Pattern.compile( "(?i)"+ Pattern.quote(query) ); + Matcher m = ptn.matcher( currentArea.getText() ); + m.region( 0, from ); + int lastStart = -1; + int lastEnd = -1; + while ( m.find() ) { + lastStart = m.start(); + lastEnd = m.end(); + } + if ( lastStart != -1 ) { + caret.setDot( lastStart ); + caret.moveDot( lastEnd ); + caret.setSelectionVisible( true ); + } + } + + private void updateCaretStatus() { + JTextArea currentArea = getCurrentArea(); + if ( currentArea == null ) return; + + try { + int offset = currentArea.getCaretPosition(); + int line = currentArea.getLineOfOffset( offset ); + int lineStart = currentArea.getLineStartOffset( line ); + int col = offset - lineStart; + int lineCount = currentArea.getLineCount(); + statusLbl.setText( String.format( "Line: %4d/%4d Col: %3d", line+1, lineCount, col+1 ) ); + } + catch ( BadLocationException e ) { + statusLbl.setText( String.format( "Line: ???/ ??? Col: ???" ) ); + } + } + + private JTextArea getCurrentArea() { + if ( areasPane.getSelectedIndex() == 0 ) + return mainArea; + else if ( areasPane.getSelectedIndex() == 1 ) + return appendArea; + else if ( areasPane.getSelectedIndex() == 2 ) + return resultArea; + else + return null; + } + + /** + * Shows a modal prompt with a JTree representing a list of paths. + * + * @return the selected path, null otherwise + */ + private String promptForInnerPath( List innerPaths ) { + String result = null; + + Set sortedPaths = new TreeSet( innerPaths ); + for ( Iterator it = sortedPaths.iterator(); it.hasNext(); ) { + if ( !it.next().endsWith(".xml") ) it.remove(); + } + + DefaultMutableTreeNode rootNode = new DefaultMutableTreeNode( "/" ); + DefaultTreeModel treeModel = new DefaultTreeModel( rootNode ); + + for ( String innerPath : sortedPaths ) { + buildTreeFromString( treeModel, innerPath ); + } + + JTree pathTree = new JTree( treeModel ); + pathTree.setRootVisible( false ); + for ( int i=0; i < pathTree.getRowCount(); i++ ) { + pathTree.expandRow( i ); + } + JScrollPane treeScroll = new JScrollPane( pathTree ); + treeScroll.setPreferredSize( new Dimension( pathTree.getPreferredSize().width, 300 ) ); + + pathTree.addAncestorListener( new FocusAncestorListener( pathTree ) ); + + int popupResult = JOptionPane.showOptionDialog( this, treeScroll, "Open an XML Resource", JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE, null, new String[]{"OK"}, "OK" ); + + if ( popupResult == JOptionPane.OK_OPTION ) { + StringBuilder buf = new StringBuilder(); + + TreePath selectedPath = pathTree.getSelectionPath(); + if ( selectedPath != null ) { + for ( Object o : selectedPath.getPath() ) { + DefaultMutableTreeNode pathComp = (DefaultMutableTreeNode)o; + if ( !pathComp.isRoot() ) { + Object userObject = pathComp.getUserObject(); + buf.append( userObject.toString() ); + } + } + if ( buf.length() > 0 ) result = buf.toString(); + } + } + + return result; + } + + /** + * Adds TreeNodes, if they don't already exist, based on a shash-delimited string. + */ + @SuppressWarnings("unchecked") + private void buildTreeFromString( DefaultTreeModel treeModel, String path ) { +// Method commented out to get application to compile. Figure out what this did and fix it +// DefaultMutableTreeNode rootNode = (DefaultMutableTreeNode)treeModel.getRoot(); +// DefaultMutableTreeNode currentNode = rootNode; +// +// String[] chunks = path.split( "/" ); +// +// for ( int i=0; i < chunks.length; i++ ) { +// String chunk = chunks[i]; +// if ( i < chunks.length-1 ) +// chunk += "/"; +// +// boolean found = false; +// Enumeration enumIt = currentNode.children(); +// while ( enumIt.hasMoreElements() ) { +// DefaultMutableTreeNode tmpNode = enumIt.nextElement(); +// if ( chunk.equals( tmpNode.getUserObject() ) ) { +// found = true; +// currentNode = tmpNode; +// break; +// } +// } +// if ( !found ) { +// DefaultMutableTreeNode newNode = new DefaultMutableTreeNode( chunk ); +// currentNode.insert( newNode, currentNode.getChildCount() ); +// currentNode = newNode; +// } +// } + } + + + + private class CaretAncestorListener implements AncestorListener { + @Override + public void ancestorAdded( AncestorEvent e ) { + updateCaretStatus(); + } + @Override + public void ancestorMoved( AncestorEvent e ) { + } + @Override + public void ancestorRemoved( AncestorEvent e ) { + } + } + + + + private static class FocusAncestorListener implements AncestorListener { + private JComponent comp; + + public FocusAncestorListener( JComponent comp ) { + this.comp = comp; + } + + @Override + public void ancestorAdded( AncestorEvent e ) { + comp.requestFocusInWindow(); + } + @Override + public void ancestorMoved( AncestorEvent e ) { + } + @Override + public void ancestorRemoved( AncestorEvent e ) { + } + } +} diff --git a/src/main/java/net/vhati/modmanager/ui/Nerfable.java b/src/main/java/net/vhati/modmanager/ui/Nerfable.java new file mode 100644 index 0000000..cba3762 --- /dev/null +++ b/src/main/java/net/vhati/modmanager/ui/Nerfable.java @@ -0,0 +1,16 @@ +package net.vhati.modmanager.ui; + + +/* + * An interface to en/disable user interaction. + * It was written with JFrames and glass panes in mind. + */ +public interface Nerfable { + + /* + * Either nerf or restore user interaction. + * + * @param b the nerfed state + */ + public void setNerfed( boolean b ); +} diff --git a/src/main/java/net/vhati/modmanager/ui/ProgressDialog.java b/src/main/java/net/vhati/modmanager/ui/ProgressDialog.java new file mode 100644 index 0000000..7f98c88 --- /dev/null +++ b/src/main/java/net/vhati/modmanager/ui/ProgressDialog.java @@ -0,0 +1,224 @@ +package net.vhati.modmanager.ui; + +import java.awt.BorderLayout; +import java.awt.Frame; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.io.File; +import javax.swing.BorderFactory; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JDialog; +import javax.swing.JPanel; +import javax.swing.JProgressBar; +import javax.swing.JScrollPane; +import javax.swing.JTextArea; +import javax.swing.SwingUtilities; + + +public class ProgressDialog extends JDialog implements ActionListener { + + protected JScrollPane statusScroll; + protected JProgressBar progressBar; + protected JTextArea statusArea; + protected JButton continueBtn; + + protected boolean continueOnSuccess = false; + protected boolean done = false; + protected boolean succeeded = false; + protected Runnable successTask = null; + + + public ProgressDialog( Frame owner, boolean continueOnSuccess ) { + super( owner, true ); + this.setDefaultCloseOperation( JDialog.DO_NOTHING_ON_CLOSE ); + + this.continueOnSuccess = continueOnSuccess; + + progressBar = new JProgressBar(); + progressBar.setBorderPainted( true ); + + JPanel progressHolder = new JPanel( new BorderLayout() ); + progressHolder.setBorder( BorderFactory.createEmptyBorder( 10, 15, 0, 15 ) ); + progressHolder.add( progressBar ); + getContentPane().add( progressHolder, BorderLayout.NORTH ); + + statusArea = new JTextArea( 5, 50 ); + statusArea.setLineWrap( true ); + statusArea.setWrapStyleWord( true ); + statusArea.setFont( statusArea.getFont().deriveFont( 13f ) ); + statusArea.setEditable( false ); + statusScroll = new JScrollPane( statusArea ); + + JPanel statusHolder = new JPanel( new BorderLayout() ); + statusHolder.setBorder( BorderFactory.createEmptyBorder( 15, 15, 15, 15 ) ); + statusHolder.add( statusScroll ); + getContentPane().add( statusHolder, BorderLayout.CENTER ); + + continueBtn = new JButton( "Continue" ); + continueBtn.setEnabled( false ); + continueBtn.addActionListener( this ); + + JPanel continueHolder = new JPanel(); + continueHolder.setLayout( new BoxLayout( continueHolder, BoxLayout.X_AXIS ) ); + continueHolder.setBorder( BorderFactory.createEmptyBorder( 0, 0, 10, 0 ) ); + continueHolder.add( Box.createHorizontalGlue() ); + continueHolder.add( continueBtn ); + continueHolder.add( Box.createHorizontalGlue() ); + getContentPane().add( continueHolder, BorderLayout.SOUTH ); + + this.pack(); + this.setMinimumSize( this.getPreferredSize() ); + this.setLocationRelativeTo( owner ); + } + + + @Override + public void actionPerformed( ActionEvent e ) { + Object source = e.getSource(); + + if ( source == continueBtn ) { + this.setVisible( false ); + this.dispose(); + + if ( done && succeeded && successTask != null ) { + successTask.run(); + } + } + } + + + /** + * Updates the text area's content. (Thread-safe) + * + * @param message a string, or null + */ + public void setStatusTextLater( final String message ) { + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + setStatusText( message != null ? message : "..." ); + } + }); + } + + protected void setStatusText( String message ) { + statusArea.setText( message != null ? message : "..." ); + statusArea.setCaretPosition( 0 ); + } + + + /** + * Updates the progress bar. (Thread-safe) + * + * If the arg is -1, the bar will become indeterminate. + * + * @param value the new value + */ + public void setProgressLater( final int value ) { + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + if ( value >= 0 ) { + if ( progressBar.isIndeterminate() ) + progressBar.setIndeterminate( false ); + + progressBar.setValue( value ); + } + else { + if ( !progressBar.isIndeterminate() ) + progressBar.setIndeterminate( true ); + progressBar.setValue( 0 ); + } + } + }); + } + + /** + * Updates the progress bar. (Thread-safe) + * + * If either arg is -1, the bar will become indeterminate. + * + * @param value the new value + * @param max the new maximum + */ + public void setProgressLater( final int value, final int max ) { + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + if ( value >= 0 && max >= 0 ) { + if ( progressBar.isIndeterminate() ) + progressBar.setIndeterminate( false ); + + if ( progressBar.getMaximum() != max ) { + progressBar.setValue( 0 ); + progressBar.setMaximum( max ); + } + progressBar.setValue( value ); + } + else { + if ( !progressBar.isIndeterminate() ) + progressBar.setIndeterminate( true ); + progressBar.setValue( 0 ); + } + } + }); + } + + + /** + * Triggers a response to the immediate task ending. (Thread-safe) + * + * If anything went wrong, e may be non-null. + */ + public void setTaskOutcomeLater( final boolean success, final Exception e ) { + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + setTaskOutcome( success, e ); + } + }); + } + + protected void setTaskOutcome( final boolean outcome, final Exception e ) { + done = true; + succeeded = outcome; + + if ( !ProgressDialog.this.isShowing() ) { + // The window's not visible, no continueBtn to click. + ProgressDialog.this.dispose(); + + if ( succeeded && successTask != null ) { + successTask.run(); + } + } + if ( continueOnSuccess && succeeded && successTask != null ) { + ProgressDialog.this.setVisible( false ); + ProgressDialog.this.dispose(); + successTask.run(); + } + else { + continueBtn.setEnabled( true ); + continueBtn.requestFocusInWindow(); + } + } + + + /** + * Sets a runnable to trigger after the immediate task ends successfully. + */ + public void setSuccessTask( Runnable r ) { + successTask = r; + } + + /** + * Shows or hides this component depending on the value of parameter b. + * + * If the immediate task has already completed, + * this method will do nothing. + */ + public void setVisible( boolean b ) { + if ( !done ) super.setVisible( b ); + } +} diff --git a/src/main/java/net/vhati/modmanager/ui/RegexDocument.java b/src/main/java/net/vhati/modmanager/ui/RegexDocument.java new file mode 100644 index 0000000..e1a03e9 --- /dev/null +++ b/src/main/java/net/vhati/modmanager/ui/RegexDocument.java @@ -0,0 +1,69 @@ +package net.vhati.modmanager.ui; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; +import javax.swing.text.AttributeSet; +import javax.swing.text.BadLocationException; +import javax.swing.text.PlainDocument; + + +/** + * A Document thats restricts characters based on a regex. + * + * @see javax.swing.JTextField.setDocument(javax.swing.text.Ducument) + */ +public class RegexDocument extends PlainDocument { + + private Pattern pattern = null; + + + public RegexDocument( Pattern p ) { + pattern = p; + } + + public RegexDocument( String regex ) { + try { + if ( regex != null && regex.length() > 0 ) { + pattern = Pattern.compile( regex ); + } + } + catch ( PatternSyntaxException e ) {} + } + + public RegexDocument() { + } + + + @Override + public void insertString( int offs, String str, AttributeSet a ) throws BadLocationException { + if ( str == null ) return; + + boolean proceed = true; + + if ( pattern != null ) { + String tmp = super.getText( 0, offs ) + str + (super.getLength()>offs ? super.getText( offs, super.getLength()-offs ) : ""); + Matcher m = pattern.matcher( tmp ); + proceed = m.matches(); + } + + if ( proceed == true ) super.insertString( offs, str, a ); + } + + + @Override + public void remove( int offs, int len ) throws BadLocationException { + boolean proceed = true; + + if ( pattern != null ) { + try { + String tmp = super.getText( 0, offs ) + (super.getLength()>(offs+len) ? super.getText( offs+len, super.getLength()-(offs+len) ) : ""); + Matcher m = pattern.matcher( tmp ); + proceed = m.matches(); + } + catch ( BadLocationException e ) {} + } + + if ( proceed == true ) super.remove( offs, len ); + } +} diff --git a/src/main/java/net/vhati/modmanager/ui/SlipstreamConfigDialog.java b/src/main/java/net/vhati/modmanager/ui/SlipstreamConfigDialog.java new file mode 100644 index 0000000..85bb94d --- /dev/null +++ b/src/main/java/net/vhati/modmanager/ui/SlipstreamConfigDialog.java @@ -0,0 +1,211 @@ +package net.vhati.modmanager.ui; + +import java.awt.BorderLayout; +import java.awt.Dimension; +import java.awt.Frame; +import java.awt.Point; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.io.File; +import javax.swing.BorderFactory; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JDialog; +import javax.swing.JFileChooser; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTextField; +import javax.swing.ScrollPaneConstants; +import javax.swing.event.AncestorEvent; +import javax.swing.event.AncestorListener; + +import net.vhati.modmanager.core.FTLUtilities; +import net.vhati.modmanager.core.SlipstreamConfig; +import net.vhati.modmanager.ui.FieldEditorPanel; +import net.vhati.modmanager.ui.FieldEditorPanel.ContentType; + + +public class SlipstreamConfigDialog extends JDialog implements ActionListener { + + protected static final String ALLOW_ZIP = SlipstreamConfig.ALLOW_ZIP; + protected static final String RUN_STEAM_FTL = SlipstreamConfig.RUN_STEAM_FTL; + protected static final String NEVER_RUN_FTL = SlipstreamConfig.NEVER_RUN_FTL; + protected static final String USE_DEFAULT_UI = SlipstreamConfig.USE_DEFAULT_UI; + protected static final String REMEMBER_GEOMETRY = SlipstreamConfig.REMEMBER_GEOMETRY; + protected static final String UPDATE_CATALOG = SlipstreamConfig.UPDATE_CATALOG; + protected static final String UPDATE_APP = SlipstreamConfig.UPDATE_APP; + protected static final String FTL_DATS_PATH = SlipstreamConfig.FTL_DATS_PATH; + protected static final String STEAM_EXE_PATH = SlipstreamConfig.STEAM_EXE_PATH; + + protected SlipstreamConfig appConfig; + + protected FieldEditorPanel editorPanel; + protected JButton applyBtn; + + + public SlipstreamConfigDialog( Frame owner, SlipstreamConfig appConfig ) { + super( owner, "Preferences..." ); + this.setDefaultCloseOperation( JDialog.DISPOSE_ON_CLOSE ); + + this.appConfig = appConfig; + + editorPanel = new FieldEditorPanel( false ); + editorPanel.setBorder( BorderFactory.createEmptyBorder( 10, 10, 0, 10 ) ); + editorPanel.setNameWidth( 250 ); + + editorPanel.addRow( ALLOW_ZIP, ContentType.BOOLEAN ); + editorPanel.addTextRow( "Treat .zip files as .ftl files." ); + editorPanel.addSeparatorRow(); + editorPanel.addRow( RUN_STEAM_FTL, ContentType.BOOLEAN ); + editorPanel.addTextRow( "Use Steam to run FTL, if possible." ); + editorPanel.addSeparatorRow(); + editorPanel.addRow( NEVER_RUN_FTL, ContentType.BOOLEAN ); + editorPanel.addTextRow( "Don't offer to run FTL after patching." ); + editorPanel.addSeparatorRow(); + editorPanel.addRow( USE_DEFAULT_UI, ContentType.BOOLEAN ); + editorPanel.addTextRow( "Don't attempt to resemble a native GUI." ); + editorPanel.addSeparatorRow(); + editorPanel.addRow( REMEMBER_GEOMETRY, ContentType.BOOLEAN ); + editorPanel.addTextRow( "Save window geometry on exit." ); + editorPanel.addSeparatorRow(); + editorPanel.addRow( UPDATE_CATALOG, ContentType.INTEGER ); + editorPanel.addTextRow( "Check for new mod descriptions every N days. (0 to disable)" ); + editorPanel.addSeparatorRow(); + editorPanel.addRow( UPDATE_APP, ContentType.INTEGER ); + editorPanel.addTextRow( "Check for newer app versions every N days. (0 to disable)" ); + editorPanel.addSeparatorRow(); + editorPanel.addRow( FTL_DATS_PATH, ContentType.CHOOSER ); + editorPanel.addTextRow( "Path to FTL's resources folder." ); + editorPanel.addSeparatorRow(); + editorPanel.addRow( STEAM_EXE_PATH, ContentType.CHOOSER ); + editorPanel.addTextRow( "Path to Steam's executable." ); + editorPanel.addSeparatorRow(); + editorPanel.addBlankRow(); + editorPanel.addTextRow( "Note: Some changes may have no immediate effect." ); + editorPanel.addBlankRow(); + editorPanel.addFillRow(); + + editorPanel.getBoolean( ALLOW_ZIP ).setSelected( "true".equals( appConfig.getProperty( SlipstreamConfig.ALLOW_ZIP, "false" ) ) ); + editorPanel.getBoolean( RUN_STEAM_FTL ).setSelected( "true".equals( appConfig.getProperty( SlipstreamConfig.RUN_STEAM_FTL, "false" ) ) ); + editorPanel.getBoolean( NEVER_RUN_FTL ).setSelected( "true".equals( appConfig.getProperty( SlipstreamConfig.NEVER_RUN_FTL, "false" ) ) ); + editorPanel.getBoolean( USE_DEFAULT_UI ).setSelected( "true".equals( appConfig.getProperty( SlipstreamConfig.USE_DEFAULT_UI, "false" ) ) ); + editorPanel.getBoolean( REMEMBER_GEOMETRY ).setSelected( "true".equals( appConfig.getProperty( SlipstreamConfig.REMEMBER_GEOMETRY, "true" ) ) ); + editorPanel.getInt( UPDATE_CATALOG ).setText( Integer.toString( appConfig.getPropertyAsInt( SlipstreamConfig.UPDATE_CATALOG, 0 ) ) ); + editorPanel.getInt( UPDATE_APP ).setText( Integer.toString( appConfig.getPropertyAsInt( SlipstreamConfig.UPDATE_APP, 0 ) ) ); + + JTextField ftlDatsPathField = editorPanel.getChooser( FTL_DATS_PATH ).getTextField(); + ftlDatsPathField.setText( appConfig.getProperty( SlipstreamConfig.FTL_DATS_PATH, "" ) ); + ftlDatsPathField.setPreferredSize( new Dimension( 150, ftlDatsPathField.getPreferredSize().height ) ); + editorPanel.getChooser( FTL_DATS_PATH ).getButton().addActionListener( this ); + + JTextField steamExePathField = editorPanel.getChooser( STEAM_EXE_PATH ).getTextField(); + steamExePathField.setText( appConfig.getProperty( SlipstreamConfig.STEAM_EXE_PATH, "" ) ); + steamExePathField.setPreferredSize( new Dimension( 150, steamExePathField.getPreferredSize().height ) ); + editorPanel.getChooser( STEAM_EXE_PATH ).getButton().addActionListener( this ); + + JPanel ctrlPanel = new JPanel(); + ctrlPanel.setLayout( new BoxLayout( ctrlPanel, BoxLayout.X_AXIS ) ); + ctrlPanel.setBorder( BorderFactory.createEmptyBorder( 10, 0, 10, 0 ) ); + ctrlPanel.add( Box.createHorizontalGlue() ); + applyBtn = new JButton( "Apply" ); + applyBtn.addActionListener( this ); + ctrlPanel.add( applyBtn ); + ctrlPanel.add( Box.createHorizontalGlue() ); + + final JScrollPane editorScroll = new JScrollPane( editorPanel ); + editorScroll.setVerticalScrollBarPolicy( ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS ); + editorScroll.getVerticalScrollBar().setUnitIncrement( 10 ); + int vbarWidth = editorScroll.getVerticalScrollBar().getPreferredSize().width; + editorScroll.setPreferredSize( new Dimension( editorPanel.getPreferredSize().width+vbarWidth+5, 400 ) ); + + JPanel contentPane = new JPanel( new BorderLayout() ); + contentPane.add( editorScroll, BorderLayout.CENTER ); + contentPane.add( ctrlPanel, BorderLayout.SOUTH ); + this.setContentPane( contentPane ); + this.pack(); + this.setMinimumSize( new Dimension( 250, 250 ) ); + + + editorScroll.addAncestorListener(new AncestorListener() { + @Override + public void ancestorAdded( AncestorEvent e ) { + editorScroll.getViewport().setViewPosition( new Point( 0, 0 ) ); + } + @Override + public void ancestorMoved( AncestorEvent e ) { + } + @Override + public void ancestorRemoved( AncestorEvent e ) { + } + }); + } + + @Override + public void actionPerformed( ActionEvent e ) { + Object source = e.getSource(); + + if ( source == applyBtn ) { + String tmp; + appConfig.setProperty( SlipstreamConfig.ALLOW_ZIP, editorPanel.getBoolean( ALLOW_ZIP ).isSelected() ? "true" : "false" ); + appConfig.setProperty( SlipstreamConfig.RUN_STEAM_FTL, editorPanel.getBoolean( RUN_STEAM_FTL ).isSelected() ? "true" : "false" ); + appConfig.setProperty( SlipstreamConfig.NEVER_RUN_FTL, editorPanel.getBoolean( NEVER_RUN_FTL ).isSelected() ? "true" : "false" ); + appConfig.setProperty( SlipstreamConfig.USE_DEFAULT_UI, editorPanel.getBoolean( USE_DEFAULT_UI ).isSelected() ? "true" : "false" ); + appConfig.setProperty( SlipstreamConfig.REMEMBER_GEOMETRY, editorPanel.getBoolean( REMEMBER_GEOMETRY ).isSelected() ? "true" : "false" ); + + tmp = editorPanel.getInt( UPDATE_CATALOG ).getText(); + try { + int n = Integer.parseInt( tmp ); + n = Math.max( 0, n ); + appConfig.setProperty( SlipstreamConfig.UPDATE_CATALOG, Integer.toString( n ) ); + } + catch ( NumberFormatException f ) {} + + tmp = editorPanel.getInt( UPDATE_APP ).getText(); + try { + int n = Integer.parseInt( tmp ); + n = Math.max( 0, n ); + appConfig.setProperty( SlipstreamConfig.UPDATE_APP, Integer.toString( n ) ); + } + catch ( NumberFormatException f ) {} + + tmp = editorPanel.getChooser( FTL_DATS_PATH ).getTextField().getText(); + if ( tmp.length() > 0 && FTLUtilities.isDatsDirValid( new File( tmp ) ) ) { + appConfig.setProperty( SlipstreamConfig.FTL_DATS_PATH, tmp ); + } + + tmp = editorPanel.getChooser( STEAM_EXE_PATH ).getTextField().getText(); + if ( tmp.length() > 0 && new File( tmp ).exists() ) { + appConfig.setProperty( SlipstreamConfig.STEAM_EXE_PATH, tmp ); + } + + this.setVisible( false ); + this.dispose(); + } + else if ( source == editorPanel.getChooser( FTL_DATS_PATH ).getButton() ) { + File datsDir = FTLUtilities.promptForDatsDir( this ); + if ( datsDir != null ) { + editorPanel.getChooser( FTL_DATS_PATH ).getTextField().setText( datsDir.getAbsolutePath() ); + } + } + else if ( source == editorPanel.getChooser( STEAM_EXE_PATH ).getButton() ) { + String currentPath = editorPanel.getChooser( STEAM_EXE_PATH ).getTextField().getText(); + + JFileChooser steamExeChooser = new JFileChooser(); + steamExeChooser.setDialogTitle( "Find Steam.exe or steam or Steam.app" ); + steamExeChooser.setFileHidingEnabled( false ); + steamExeChooser.setMultiSelectionEnabled( false ); + if ( currentPath.length() > 0 ) { + steamExeChooser.setCurrentDirectory( new File( currentPath ) ); + } + + if ( steamExeChooser.showOpenDialog( null ) == JFileChooser.APPROVE_OPTION ) { + File steamExeFile = steamExeChooser.getSelectedFile(); + if ( steamExeFile.exists() ) { + editorPanel.getChooser( STEAM_EXE_PATH ).getTextField().setText( steamExeFile.getAbsolutePath() ); + } + } + } + } +} diff --git a/src/main/java/net/vhati/modmanager/ui/Statusbar.java b/src/main/java/net/vhati/modmanager/ui/Statusbar.java new file mode 100644 index 0000000..e148bcd --- /dev/null +++ b/src/main/java/net/vhati/modmanager/ui/Statusbar.java @@ -0,0 +1,6 @@ +package net.vhati.modmanager.ui; + + +public interface Statusbar { + public void setStatusText( String text ); +} diff --git a/src/main/java/net/vhati/modmanager/ui/StatusbarMouseListener.java b/src/main/java/net/vhati/modmanager/ui/StatusbarMouseListener.java new file mode 100644 index 0000000..b8685e2 --- /dev/null +++ b/src/main/java/net/vhati/modmanager/ui/StatusbarMouseListener.java @@ -0,0 +1,38 @@ +package net.vhati.modmanager.ui; + +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.event.MouseListener; + +import net.vhati.modmanager.ui.Statusbar; + + +/** + * A MouseListener to show rollover help text in a status bar. + * + * Construct this with the help text, and a class + * implementing the Statusbar interface. + * + * Then add this mouseListener to a component. + */ +public class StatusbarMouseListener extends MouseAdapter { + + protected Statusbar bar = null; + protected String text = null; + + + public StatusbarMouseListener( Statusbar bar, String text ) { + this.bar = bar; + this.text = text; + } + + @Override + public void mouseEntered( MouseEvent e ) { + bar.setStatusText( text ); + } + + @Override + public void mouseExited( MouseEvent e ) { + bar.setStatusText( "" ); + } +} diff --git a/src/main/java/net/vhati/modmanager/ui/table/ChecklistTableModel.java b/src/main/java/net/vhati/modmanager/ui/table/ChecklistTableModel.java new file mode 100644 index 0000000..155d21e --- /dev/null +++ b/src/main/java/net/vhati/modmanager/ui/table/ChecklistTableModel.java @@ -0,0 +1,117 @@ +package net.vhati.modmanager.ui.table; + +import java.util.ArrayList; +import java.util.List; +import javax.swing.table.AbstractTableModel; + +import net.vhati.modmanager.core.ModInfo; +import net.vhati.modmanager.ui.table.Reorderable; + + +public class ChecklistTableModel extends AbstractTableModel implements Reorderable { + + private static final int COLUMN_CHECK = 0; + private static final int COLUMN_PAYLOAD = 1; + + private static final int DATA_CHECK = 0; + private static final int DATA_PAYLOAD = 1; + + private String[] columnNames = new String[] {"?", "Name"}; + private Class[] columnTypes = new Class[] {Boolean.class, String.class}; + + private List> rowsList = new ArrayList>(); + + + public void addItem( T o ) { + insertItem( rowsList.size(), false, o ); + } + + public void insertItem( int row, boolean selected, T o ) { + int newRowIndex = rowsList.size(); + + List rowData = new ArrayList(); + rowData.add( new Boolean(selected) ); + rowData.add( o ); + rowsList.add( row, rowData ); + + fireTableRowsInserted( row, row ); + } + + public void removeItem( int row ) { + rowsList.remove( row ); + fireTableRowsDeleted( row, row ); + } + + public void removeAllItems() { + rowsList.clear(); + fireTableDataChanged(); + } + + @SuppressWarnings("unchecked") + public T getItem( int row ) { + return (T)rowsList.get(row).get( DATA_PAYLOAD ); + } + + @Override + public void reorder( int fromRow, int toRow ) { + if ( toRow > fromRow ) toRow--; + List rowData = rowsList.get( fromRow ); + rowsList.remove( fromRow ); + fireTableRowsDeleted( fromRow, fromRow ); + rowsList.add( toRow, rowData ); + fireTableRowsInserted( toRow, toRow ); + } + + public void setSelected( int row, boolean b ) { + rowsList.get(row).set( DATA_CHECK, new Boolean( b ) ); + fireTableRowsUpdated( row, row ); + } + + @SuppressWarnings("unchecked") + public boolean isSelected( int row ) { + return ((Boolean)rowsList.get( row ).get( DATA_CHECK )).booleanValue(); + } + + @Override + public int getColumnCount() { + return columnNames.length; + } + + @Override + public int getRowCount() { + return rowsList.size(); + } + + @Override + public Object getValueAt( int row, int column ) { + if ( column == COLUMN_CHECK ) { + return rowsList.get( row ).get( DATA_CHECK ); + } + else if ( column == COLUMN_PAYLOAD ) { + Object o = rowsList.get( row ).get( DATA_PAYLOAD ); + return o.toString(); + } + throw new ArrayIndexOutOfBoundsException(); + } + + @Override + @SuppressWarnings("unchecked") + public void setValueAt( Object o, int row, int column ) { + if ( column == COLUMN_CHECK ) { + Boolean bool = (Boolean)o; + rowsList.get(row).set( DATA_CHECK, bool ); + fireTableRowsUpdated( row, row ); + } + } + + @Override + public boolean isCellEditable( int row, int column ) { + if ( column == COLUMN_CHECK ) return true; + return false; + } + + @Override + public Class getColumnClass( int column ) { + return columnTypes[column]; + } +} diff --git a/src/main/java/net/vhati/modmanager/ui/table/ChecklistTablePanel.java b/src/main/java/net/vhati/modmanager/ui/table/ChecklistTablePanel.java new file mode 100644 index 0000000..3130f28 --- /dev/null +++ b/src/main/java/net/vhati/modmanager/ui/table/ChecklistTablePanel.java @@ -0,0 +1,136 @@ +package net.vhati.modmanager.ui.table; + +import java.awt.BorderLayout; +import java.awt.Dimension; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.ArrayList; +import java.util.List; +import javax.swing.DropMode; +import javax.swing.ListSelectionModel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTable; + +import net.vhati.modmanager.core.ModFileInfo; +import net.vhati.modmanager.ui.table.ChecklistTableModel; +import net.vhati.modmanager.ui.table.TableRowTransferHandler; + + +public class ChecklistTablePanel extends JPanel { + + protected ChecklistTableModeltableModel; + protected JTable table; + + + public ChecklistTablePanel() { + super( new BorderLayout() ); + + tableModel = new ChecklistTableModel(); + + table = new JTable( tableModel ); + table.setFillsViewportHeight( true ); + table.setSelectionMode( ListSelectionModel.SINGLE_SELECTION ); + table.setTableHeader( null ); + table.getColumnModel().getColumn( 0 ).setMinWidth( 30 ); + table.getColumnModel().getColumn( 0 ).setMaxWidth( 30 ); + table.getColumnModel().getColumn( 0 ).setPreferredWidth( 30 ); + + JScrollPane scrollPane = new JScrollPane( null, JScrollPane.VERTICAL_SCROLLBAR_ALWAYS, JScrollPane.HORIZONTAL_SCROLLBAR_NEVER ); + scrollPane.setViewportView( table ); + //scrollPane.setColumnHeaderView( null ); // Counterpart to setTableHeader(). + scrollPane.setPreferredSize( new Dimension( Integer.MIN_VALUE, Integer.MIN_VALUE ) ); + this.add( scrollPane, BorderLayout.CENTER ); + + + // Double-click toggles checkboxes. + table.addMouseListener(new MouseAdapter() { + int prevRow = -1; + int streak = 0; + + @Override + public void mouseClicked( MouseEvent e ) { + if ( e.getSource() != table ) return; + int thisRow = table.rowAtPoint( e.getPoint() ); + + // Reset on first click and when no longer on that row. + if ( e.getClickCount() == 1 ) prevRow = -1; + + if ( thisRow != prevRow || thisRow == -1 ) { + streak = 1; + prevRow = thisRow; + return; + } + else { + streak++; + } + if ( streak % 2 != 0 ) return; // Respond only to click pairs. + + // Don't further toggle a multi-clicked checkbox. + int viewCol = table.columnAtPoint( e.getPoint() ); + int modelCol = table.getColumnModel().getColumn( viewCol ).getModelIndex(); + if ( modelCol == 0 ) return; + + int selRow = table.getSelectedRow(); + if ( selRow != -1 ) { + boolean selected = tableModel.isSelected( selRow ); + tableModel.setSelected( selRow, !selected ); + } + } + }); + + table.setTransferHandler( new TableRowTransferHandler( table ) ); + table.setDropMode( DropMode.INSERT ); // Drop between rows, not on them. + table.setDragEnabled( true ); + } + + + public void clear() { + tableModel.removeAllItems(); + } + + + public List getAllItems() { + List results = new ArrayList(); + + for ( int i=0; i < tableModel.getRowCount(); i++ ) { + results.add( tableModel.getItem( i ) ); + } + + return results; + } + + public List getSelectedItems() { + List results = new ArrayList(); + + for ( int i=0; i < tableModel.getRowCount(); i++ ) { + if ( tableModel.isSelected( i ) ) { + results.add( tableModel.getItem( i ) ); + } + } + + return results; + } + + + public void toggleAllItemSelection() { + int selectedCount = 0; + for ( int i = tableModel.getRowCount()-1; i >= 0; i-- ) { + if ( tableModel.isSelected( i ) ) selectedCount++; + } + boolean b = ( selectedCount != tableModel.getRowCount() ); + + for ( int i = tableModel.getRowCount()-1; i >= 0; i-- ) { + tableModel.setSelected( i, b ); + } + } + + + public ChecklistTableModel getTableModel() { + return tableModel; + } + + public JTable getTable() { + return table; + } +} diff --git a/src/main/java/net/vhati/modmanager/ui/table/ListState.java b/src/main/java/net/vhati/modmanager/ui/table/ListState.java new file mode 100644 index 0000000..916fd55 --- /dev/null +++ b/src/main/java/net/vhati/modmanager/ui/table/ListState.java @@ -0,0 +1,39 @@ +package net.vhati.modmanager.ui.table; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + + +/** + * An implementation-agnostic model to pass between the GUI thread and the + * (de)serializer. + */ +public class ListState { + + protected List items = new ArrayList(); + + + public ListState() { + } + + + public void addItem( T item ) { + items.add( item ); + } + + /** + * Returns a new list containing items in this state. + */ + public List getItems() { + return new ArrayList( items ); + } + + public void removeItem( T item ) { + items.remove( item ); + } + + public boolean containsItem( T item ) { + return items.contains( item ); + } +} diff --git a/src/main/java/net/vhati/modmanager/ui/table/Reorderable.java b/src/main/java/net/vhati/modmanager/ui/table/Reorderable.java new file mode 100644 index 0000000..64ce492 --- /dev/null +++ b/src/main/java/net/vhati/modmanager/ui/table/Reorderable.java @@ -0,0 +1,9 @@ +package net.vhati.modmanager.ui.table; + + +public interface Reorderable { + /** + * Moves an element at fromIndex to toIndex. + */ + public void reorder( int fromIndex, int toIndex ); +} diff --git a/src/main/java/net/vhati/modmanager/ui/table/TableRowTransferHandler.java b/src/main/java/net/vhati/modmanager/ui/table/TableRowTransferHandler.java new file mode 100644 index 0000000..cd7208f --- /dev/null +++ b/src/main/java/net/vhati/modmanager/ui/table/TableRowTransferHandler.java @@ -0,0 +1,131 @@ +package net.vhati.modmanager.ui.table; + +import java.awt.Cursor; +import java.awt.datatransfer.DataFlavor; +import java.awt.datatransfer.Transferable; +import java.awt.dnd.DragSource; +import javax.swing.JComponent; +import javax.swing.JTable; +import javax.swing.TransferHandler; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import net.vhati.modmanager.ui.table.Reorderable; + + +/** + * Allows drag and drop reordering of JTable rows. + * + * Its TableModel must implement the Reorderable interface. + */ +public class TableRowTransferHandler extends TransferHandler { + + private static final Logger log = LoggerFactory.getLogger( TableRowTransferHandler.class ); + + private DataFlavor localIntegerFlavor = null; + + private JTable table = null; + + + public TableRowTransferHandler( JTable table ) { + super(); + if ( table.getModel() instanceof Reorderable == false ) { + throw new IllegalArgumentException( "The tableModel doesn't implement Reorderable." ); + } + this.table = table; + + try { + localIntegerFlavor = new DataFlavor( String.format( "%s;class=\"%s\"", DataFlavor.javaJVMLocalObjectMimeType, Integer.class.getName() ) ); + } + catch ( ClassNotFoundException e ) { + log.error( "Failed to construct a table row transfer handler", e ); + } + } + + @Override + protected Transferable createTransferable( JComponent c ) { + assert ( c == table ); + int row = table.getSelectedRow(); + return new IntegerTransferrable( new Integer( row ) ); + } + + @Override + public boolean canImport( TransferHandler.TransferSupport ts ) { + boolean b = ( ts.getComponent() == table && ts.isDrop() && ts.isDataFlavorSupported( localIntegerFlavor ) ); + table.setCursor( b ? DragSource.DefaultMoveDrop : DragSource.DefaultMoveNoDrop ); + return b; + } + + @Override + public int getSourceActions( JComponent comp ) { + return TransferHandler.MOVE; + } + + @Override + @SuppressWarnings("Unchecked") + public boolean importData( TransferHandler.TransferSupport ts ) { + if ( !canImport( ts ) ) return false; + + JTable target = (JTable)ts.getComponent(); + JTable.DropLocation dl = (JTable.DropLocation)ts.getDropLocation(); + int dropRow = dl.getRow(); + int rowCount = table.getModel().getRowCount(); + if ( dropRow < 0 || dropRow > rowCount ) dropRow = rowCount; + + target.setCursor( Cursor.getPredefinedCursor( Cursor.DEFAULT_CURSOR ) ); + try { + Integer draggedRow = (Integer)ts.getTransferable().getTransferData( localIntegerFlavor ); + if ( draggedRow != -1 && draggedRow != dropRow ) { + ((Reorderable)table.getModel()).reorder( draggedRow, dropRow ); + if ( dropRow > draggedRow ) dropRow--; + target.getSelectionModel().addSelectionInterval( dropRow, dropRow ); + return true; + } + } + catch ( Exception e ) { + log.error( "Dragging failed", e ); + } + return false; + } + + @Override + protected void exportDone( JComponent source, Transferable data, int action ) { + if ( action == TransferHandler.MOVE || action == TransferHandler.NONE ) { + table.setCursor( Cursor.getPredefinedCursor( Cursor.DEFAULT_CURSOR ) ); + } + } + + + + /** + * Drag and drop Integer data, constructed with a raw object + * from a drag source, to be transformed into a flavor + * suitable for the drop target. + */ + private class IntegerTransferrable implements Transferable { + private Integer data; + + public IntegerTransferrable( Integer data ) { + this.data = data; + } + + @Override + public Object getTransferData( DataFlavor flavor ) { + if ( flavor.equals( localIntegerFlavor ) ) { + return data; + } + return null; + } + + @Override + public DataFlavor[] getTransferDataFlavors() { + return new DataFlavor[] {localIntegerFlavor}; + } + + @Override + public boolean isDataFlavorSupported( DataFlavor flavor ) { + return flavor.equals( localIntegerFlavor ); + } + } +} diff --git a/src/main/java/net/vhati/modmanager/ui/tree/ChecklistTreeCellRenderer.java b/src/main/java/net/vhati/modmanager/ui/tree/ChecklistTreeCellRenderer.java new file mode 100644 index 0000000..9e11063 --- /dev/null +++ b/src/main/java/net/vhati/modmanager/ui/tree/ChecklistTreeCellRenderer.java @@ -0,0 +1,91 @@ +package net.vhati.modmanager.ui.tree; + +import java.awt.BorderLayout; +import java.awt.Component; +import javax.swing.JPanel; +import javax.swing.JTree; +import javax.swing.tree.TreeCellRenderer; +import javax.swing.tree.TreePath; + +import net.vhati.modmanager.ui.tree.ChecklistTreePathFilter; +import net.vhati.modmanager.ui.tree.ChecklistTreeSelectionModel; +import net.vhati.modmanager.ui.tree.TristateCheckBox; +import net.vhati.modmanager.ui.tree.TristateButtonModel.TristateState; + + +/** + * A cell renderer that augments an existing renderer with a checkbox. + */ +public class ChecklistTreeCellRenderer extends JPanel implements TreeCellRenderer { + + protected ChecklistTreeSelectionModel selectionModel; + protected ChecklistTreePathFilter checklistFilter; + protected TreeCellRenderer delegate; + protected TristateCheckBox checkbox = new TristateCheckBox(); + protected int checkMaxX = 0; + + + /** + * Constructor. + * + * @param delegate a traditional TreeCellRenderer + * @param selectionModel a model to query for checkbox states + * @param checklistFilter a TreePath filter, or null to always show a checkbox + */ + public ChecklistTreeCellRenderer( TreeCellRenderer delegate, ChecklistTreeSelectionModel selectionModel, ChecklistTreePathFilter checklistFilter ) { + super(); + this.delegate = delegate; + this.selectionModel = selectionModel; + this.checklistFilter = checklistFilter; + + this.setLayout( new BorderLayout() ); + this.setOpaque( false ); + checkbox.setOpaque( false ); + + checkMaxX = checkbox.getPreferredSize().width; + } + + + @Override + public Component getTreeCellRendererComponent( JTree tree, Object value, boolean sel, boolean expanded, boolean leaf, int row, boolean hasFocus ) { + this.removeAll(); + checkbox.setState( TristateState.DESELECTED ); + + Component delegateComp = delegate.getTreeCellRendererComponent( tree, value, sel, expanded, leaf, row, hasFocus ); + + TreePath path = tree.getPathForRow( row ); + if ( path != null ) { + if ( selectionModel.isPathSelected( path, selectionModel.isDigged() ) ) { + checkbox.setState( TristateState.SELECTED ); + } else { + checkbox.setState( ( selectionModel.isDigged() && selectionModel.isPartiallySelected( path ) ) ? TristateState.INDETERMINATE : TristateState.DESELECTED ); + } + } + checkbox.setVisible( path == null || checklistFilter == null || checklistFilter.isSelectable( path ) ); + checkbox.setEnabled( tree.isEnabled() ); + + this.add( checkbox, BorderLayout.WEST ); + this.add( delegateComp, BorderLayout.CENTER ); + return this; + } + + + public void setDelegate( TreeCellRenderer delegate ) { + this.delegate = delegate; + } + + public TreeCellRenderer getDelegate() { + return delegate; + } + + + /** + * Returns the checkbox's right edge (in the renderer component's coordinate space). + * + * Values less than that can be interpreted as within the checkbox's bounds. + * X=0 is the renderer component's left edge. + */ + public int getCheckboxMaxX() { + return checkMaxX; + } +} diff --git a/src/main/java/net/vhati/modmanager/ui/tree/ChecklistTreeManager.java b/src/main/java/net/vhati/modmanager/ui/tree/ChecklistTreeManager.java new file mode 100644 index 0000000..84f83f6 --- /dev/null +++ b/src/main/java/net/vhati/modmanager/ui/tree/ChecklistTreeManager.java @@ -0,0 +1,129 @@ +/** + * Based on CheckTreeManager (rev 120, 2007-07-20) + * By Santhosh Kumar T + * https://java.net/projects/myswing + * + * https://java.net/projects/myswing/sources/svn/content/trunk/src/skt/swing/tree/check/CheckTreeManager.java?rev=120 + */ + +/** + * MySwing: Advanced Swing Utilites + * Copyright (C) 2005 Santhosh Kumar T + *

+ * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + *

+ * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + */ + +package net.vhati.modmanager.ui.tree; + +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import javax.swing.JTree; +import javax.swing.event.TreeSelectionEvent; +import javax.swing.event.TreeSelectionListener; +import javax.swing.tree.TreePath; + +import net.vhati.modmanager.ui.tree.ChecklistTreePathFilter; +import net.vhati.modmanager.ui.tree.ChecklistTreeSelectionModel; + + +public class ChecklistTreeManager extends MouseAdapter implements TreeSelectionListener { + + private ChecklistTreeSelectionModel selectionModel; + private ChecklistTreePathFilter checklistFilter; + protected JTree tree = new JTree(); + protected int checkMaxX = 0; + + + /** + * Constructor. + * + * Modifies a given tree to add checkboxes. + * - The tree's existing cell renderer will be wrapped with a ChecklistTreeCellRenderer. + * - A MouseListener will be added to the tree to detect clicks, which will toggle checkboxes. + * + * A secondary ChecklistTreeSelectionModel will track checkboxes' states (independent of row + * highlighting). + * + * @param tree a tree to modify + * @param dig true show that a node is partially selected by scanning its descendents, false otherwise + * @checklistFilter a filter to decide which TreePaths need checkboxes, or null + */ + public ChecklistTreeManager( JTree tree, boolean dig, ChecklistTreePathFilter checklistFilter ) { + this.tree = tree; + this.checklistFilter = checklistFilter; + + // Note: If largemodel is not set then treenodes are getting truncated. + // Need to debug further to find the problem. + if ( checklistFilter != null ) tree.setLargeModel( true ); + + selectionModel = new ChecklistTreeSelectionModel( tree.getModel(), dig ); + + ChecklistTreeCellRenderer checklistRenderer = new ChecklistTreeCellRenderer( tree.getCellRenderer(), selectionModel, checklistFilter ); + setCheckboxMaxX( checklistRenderer.getCheckboxMaxX() ); + tree.setCellRenderer( checklistRenderer ); + + selectionModel.addTreeSelectionListener( this ); + tree.addMouseListener( this ); + } + + + /** + * Sets the checkbox's right edge (in the TreeCellRenderer component's coordinate space). + * + * Values less than that will be interpreted as within the checkbox's bounds. + * X=0 is the renderer component's left edge. + */ + public void setCheckboxMaxX( int x ) { + checkMaxX = x; + } + + + public ChecklistTreePathFilter getChecklistFilter() { + return checklistFilter; + } + + + public ChecklistTreeSelectionModel getSelectionModel() { + return selectionModel; + } + + + @Override + public void mouseClicked( MouseEvent e ) { + TreePath path = tree.getPathForLocation( e.getX(), e.getY() ); + if ( path == null ) return; + + if ( e.getX() > tree.getPathBounds(path).x + checkMaxX ) return; + + if ( checklistFilter != null && !checklistFilter.isSelectable(path) ) return; + + boolean selected = selectionModel.isPathSelected( path, selectionModel.isDigged() ); + selectionModel.removeTreeSelectionListener( this ); + + try { + if ( selected ) { + selectionModel.removeSelectionPath( path ); + } else { + selectionModel.addSelectionPath( path ); + } + } + finally { + selectionModel.addTreeSelectionListener( this ); + tree.treeDidChange(); + } + } + + + @Override + public void valueChanged( TreeSelectionEvent e ) { + tree.treeDidChange(); + } +} diff --git a/src/main/java/net/vhati/modmanager/ui/tree/ChecklistTreePanel.java b/src/main/java/net/vhati/modmanager/ui/tree/ChecklistTreePanel.java new file mode 100644 index 0000000..103273d --- /dev/null +++ b/src/main/java/net/vhati/modmanager/ui/tree/ChecklistTreePanel.java @@ -0,0 +1,196 @@ +package net.vhati.modmanager.ui.tree; + +import java.awt.BorderLayout; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.List; +import javax.swing.DropMode; +import javax.swing.JFrame; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTree; +import javax.swing.SwingUtilities; +import javax.swing.tree.DefaultMutableTreeNode; +import javax.swing.tree.DefaultTreeCellRenderer; +import javax.swing.tree.DefaultTreeModel; +import javax.swing.tree.TreePath; +import javax.swing.tree.TreeSelectionModel; + +import net.vhati.modmanager.ui.tree.ChecklistTreeManager; +import net.vhati.modmanager.ui.tree.ChecklistTreeSelectionModel; +import net.vhati.modmanager.ui.tree.TreeTransferHandler; + + +public class ChecklistTreePanel extends JPanel { + + private DefaultTreeModel treeModel = null; + private JTree tree = null; + private ChecklistTreeManager checklistManager = null; + + + public ChecklistTreePanel() { + super( new BorderLayout() ); + + DefaultMutableTreeNode rootNode = new DefaultMutableTreeNode( "Root", true ); + treeModel = new DefaultTreeModel( rootNode, true ); + tree = new JTree( treeModel ); + tree.setCellRenderer( new DefaultTreeCellRenderer() ); + tree.setRootVisible( false ); + tree.getSelectionModel().setSelectionMode( TreeSelectionModel.DISCONTIGUOUS_TREE_SELECTION ); + checklistManager = new ChecklistTreeManager( tree, true, null ); + + JScrollPane scrollPane = new JScrollPane( tree, JScrollPane.VERTICAL_SCROLLBAR_ALWAYS, JScrollPane.HORIZONTAL_SCROLLBAR_AS_NEEDED ); + this.add( scrollPane, BorderLayout.CENTER ); + + tree.setTransferHandler( new TreeTransferHandler( tree ) ); + tree.setDropMode( DropMode.ON_OR_INSERT ); // Drop between rows, or onto groups. + tree.setDragEnabled( true ); + } + + + /** + * Returns all userObjects of nodes with ticked checkboxes (except root itself). + */ + public List getSelectedUserObjects() { + ChecklistTreeSelectionModel checklistSelectionModel = checklistManager.getSelectionModel(); + List results = new ArrayList(); + + for ( Enumeration enumer = checklistSelectionModel.getAllSelectedPaths(); enumer.hasMoreElements(); ) { + DefaultMutableTreeNode childNode = (DefaultMutableTreeNode)enumer.nextElement(); + if ( !childNode.isRoot() && childNode.getUserObject() != null ) { + results.add( childNode.getUserObject() ); + } + } + + return results; + } + + /** + * Returns all userObjects of all nodes (except root itself). + */ + public List getAllUserObjects() { + List results = new ArrayList(); + + DefaultMutableTreeNode rootNode = (DefaultMutableTreeNode)treeModel.getRoot(); + getAllUserObjects( rootNode, results ); + + return results; + } + + private void getAllUserObjects( DefaultMutableTreeNode currentNode, List results ) { + if ( !currentNode.isRoot() && currentNode.getUserObject() != null ) { + results.add( currentNode.getUserObject() ); + } + + for ( Enumeration enumer = currentNode.children(); enumer.hasMoreElements(); ) { + DefaultMutableTreeNode childNode = (DefaultMutableTreeNode)enumer.nextElement(); + getAllUserObjects( currentNode, results ); + } + } + + + public void clear() { + DefaultMutableTreeNode rootNode = (DefaultMutableTreeNode)treeModel.getRoot(); + rootNode.removeAllChildren(); + treeModel.reload(); + } + + + /** + * Adds a group to consolidate mods. + * + * TODO: Trigger a rename. + */ + public void addGroup() { + DefaultMutableTreeNode rootNode = (DefaultMutableTreeNode)treeModel.getRoot(); + DefaultMutableTreeNode groupNode = new DefaultMutableTreeNode( "New Group", true ); + rootNode.add( groupNode ); + treeModel.nodesWereInserted( rootNode, new int[]{rootNode.getIndex( groupNode )} ); + } + + /** + * Disband selected groups. + * + * TODO + */ + public void removeSelectedGroups() { + } + + /** + * Rename last selected group. + * + * TODO + */ + public void renameSelectedGroup() { + } + + + /** + * Cycles through ticking all checkboxes and clearing them. + */ + public void toggleAllNodeSelection() { + } + + /** + * Cycles through expanding all nodes and collapsing them. + */ + public void toggleAllNodeExpansion() { + boolean canExpand = false; + boolean canCollapse = false; + + for ( int i = tree.getRowCount()-1; i >= 0; i-- ) { + if ( tree.isCollapsed( i ) ) { + canExpand = true; + } + else if ( tree.isExpanded( i ) ) { + canCollapse = true; + } + } + + if ( canExpand ) { + expandAllNodes( tree.getRowCount() ); + } + else if ( canCollapse ) { + collapseAllNodes( new TreePath( treeModel.getRoot() ) ); + } + } + + /** + * Expands all nodes by repeatedly expanding until the row count stops + * growing. + */ + public void expandAllNodes( int prevRowCount ) { + for ( int i=0; i < prevRowCount; i++ ) { + tree.expandRow( i ); + } + if ( tree.getRowCount() != prevRowCount ) { + expandAllNodes( tree.getRowCount() ); + } + } + + /** + * Collapses all nodes by walking the TreeModel. + */ + public void collapseAllNodes( TreePath currentPath ) { + Object currentNode = currentPath.getLastPathComponent(); + for ( int i = treeModel.getChildCount( currentNode )-1; i >= 0; i-- ) { + Object childNode = treeModel.getChild( currentNode, i ); + TreePath childPath = currentPath.pathByAddingChild( childNode ); + collapseAllNodes( childPath ); + } + if ( currentNode != treeModel.getRoot() ) tree.collapsePath( currentPath ); + } + + + public JTree getTree() { + return tree; + } + + public DefaultTreeModel getTreeModel() { + return treeModel; + } + + public ChecklistTreeManager getChecklistManager() { + return checklistManager; + } +} diff --git a/src/main/java/net/vhati/modmanager/ui/tree/ChecklistTreePathFilter.java b/src/main/java/net/vhati/modmanager/ui/tree/ChecklistTreePathFilter.java new file mode 100644 index 0000000..5bb5969 --- /dev/null +++ b/src/main/java/net/vhati/modmanager/ui/tree/ChecklistTreePathFilter.java @@ -0,0 +1,13 @@ +package net.vhati.modmanager.ui.tree; + +import javax.swing.tree.TreePath; + + +/** + * Decides whether a given TreePath should have a checkbox. + */ +public interface ChecklistTreePathFilter { + + public boolean isSelectable( TreePath path ); + +} diff --git a/src/main/java/net/vhati/modmanager/ui/tree/ChecklistTreeSelectionModel.java b/src/main/java/net/vhati/modmanager/ui/tree/ChecklistTreeSelectionModel.java new file mode 100644 index 0000000..acffef9 --- /dev/null +++ b/src/main/java/net/vhati/modmanager/ui/tree/ChecklistTreeSelectionModel.java @@ -0,0 +1,265 @@ +/* + * Based on CheckTreeSelectionModel (rev 120, 2007-07-20) + * By Santhosh Kumar T + * https://java.net/projects/myswing + * + * https://java.net/projects/myswing/sources/svn/content/trunk/src/skt/swing/tree/check/CheckTreeSelectionModel.java?rev=120 + */ + +/** + * MySwing: Advanced Swing Utilites + * Copyright (C) 2005 Santhosh Kumar T + *

+ * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + *

+ * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + */ + +package net.vhati.modmanager.ui.tree; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Enumeration; +import java.util.List; +import java.util.Stack; +import javax.swing.tree.DefaultTreeSelectionModel; +import javax.swing.tree.TreeModel; +import javax.swing.tree.TreePath; +import javax.swing.tree.TreeSelectionModel; + +import net.vhati.modmanager.ui.tree.PreorderEnumeration; + + +public class ChecklistTreeSelectionModel extends DefaultTreeSelectionModel { + + private TreeModel model; + private boolean dig = true; + + + public ChecklistTreeSelectionModel( TreeModel model, boolean dig ) { + this.model = model; + this.dig = dig; + this.setSelectionMode( TreeSelectionModel.DISCONTIGUOUS_TREE_SELECTION ); + } + + public boolean isDigged() { + return dig; + } + + + /** + * Returns true if path1 is a descendant of path2. + */ + private boolean isDescendant( TreePath path1, TreePath path2 ) { + Object obj1[] = path1.getPath(); + Object obj2[] = path2.getPath(); + for ( int i=0; i < obj2.length; i++ ) { + if ( obj1[i] != obj2[i] ) return false; + } + return true; + } + + + /** + * Returns true a selected node exists in the subtree of a given unselected path. + * Returns false if the given path is itself selected. + */ + public boolean isPartiallySelected( TreePath path ) { + if ( isPathSelected( path, true ) ) return false; + + TreePath[] selectionPaths = getSelectionPaths(); + if( selectionPaths == null ) return false; + + for ( int j=0; j < selectionPaths.length; j++ ) { + if ( isDescendant( selectionPaths[j], path ) ) { + return true; + } + } + return false; + } + + /** + * Returns true if a given path is selected. + * + * If dig is true, then the path is assumed to be selected, if + * one of its ancestors is selected. + */ + public boolean isPathSelected( TreePath path, boolean dig ) { + if ( !dig ) return super.isPathSelected( path ); + + while ( path != null && !super.isPathSelected( path ) ) { + path = path.getParentPath(); + } + return ( path != null ); + } + + + @Override + public void setSelectionPaths( TreePath[] paths ) { + if ( dig ) { + throw new UnsupportedOperationException(); + } else { + super.setSelectionPaths( paths ); + } + } + + @Override + public void addSelectionPaths( TreePath[] paths ) { + if ( !dig ) { + super.addSelectionPaths( paths ); + return; + } + + // Unselect all descendants of paths[]. + for( int i=0; i < paths.length; i++ ) { + TreePath path = paths[i]; + TreePath[] selectionPaths = getSelectionPaths(); + if ( selectionPaths == null ) break; + + List toBeRemoved = new ArrayList(); + for ( int j=0; j < selectionPaths.length; j++ ) { + if ( isDescendant( selectionPaths[j], path ) ) { + toBeRemoved.add( selectionPaths[j] ); + } + } + super.removeSelectionPaths( (TreePath[])toBeRemoved.toArray( new TreePath[0] ) ); + } + + // If all siblings are selected then unselect them and select parent recursively + // otherwize just select that path. + for ( int i=0; i < paths.length; i++ ) { + TreePath path = paths[i]; + TreePath temp = null; + while ( areSiblingsSelected(path) ) { + temp = path; + if ( path.getParentPath() == null ) break; + path = path.getParentPath(); + } + if ( temp != null ) { + if ( temp.getParentPath() != null ) { + addSelectionPath( temp.getParentPath() ); + } + else { + if ( !isSelectionEmpty() ) { + removeSelectionPaths( getSelectionPaths() ); + } + super.addSelectionPaths( new TreePath[]{temp} ); + } + } + else { + super.addSelectionPaths( new TreePath[]{path} ); + } + } + } + + @Override + public void removeSelectionPaths( TreePath[] paths ) { + if( !dig ) { + super.removeSelectionPaths( paths ); + return; + } + + for ( int i=0; i < paths.length; i++ ) { + TreePath path = paths[i]; + if ( path.getPathCount() == 1 ) { + super.removeSelectionPaths( new TreePath[]{path} ); + } else { + toggleRemoveSelection( path ); + } + } + } + + + /** + * Returns true if all siblings of given path are selected. + */ + private boolean areSiblingsSelected( TreePath path ) { + TreePath parent = path.getParentPath(); + if ( parent == null ) return true; + + Object node = path.getLastPathComponent(); + Object parentNode = parent.getLastPathComponent(); + + int childCount = model.getChildCount( parentNode ); + for ( int i=0; i < childCount; i++ ) { + Object childNode = model.getChild( parentNode, i ); + if ( childNode == node ) continue; + + if ( !isPathSelected( parent.pathByAddingChild( childNode ) ) ) { + return false; + } + } + return true; + } + + + /** + * Unselects a given path, toggling ancestors if they were entirely selected. + * + * If any ancestor node of the given path is selected, it will be unselected, + * and all its descendants - except any within the given path - will be selected. + * The ancestor will have gone from fully selected to partially selected. + * + * Otherwise, the given path will be unselected, and nothing else will change. + */ + private void toggleRemoveSelection( TreePath path ) { + Stack stack = new Stack(); + TreePath parent = path.getParentPath(); + + while ( parent != null && !isPathSelected( parent ) ) { + stack.push( parent ); + parent = parent.getParentPath(); + } + + if ( parent != null ) { + stack.push( parent ); + } + else { + super.removeSelectionPaths( new TreePath[]{path} ); + return; + } + + while ( !stack.isEmpty() ) { + TreePath temp = stack.pop(); + TreePath peekPath = ( stack.isEmpty() ? path : stack.peek() ); + Object node = temp.getLastPathComponent(); + Object peekNode = peekPath.getLastPathComponent(); + + int childCount = model.getChildCount( node ); + for ( int i=0; i < childCount; i++ ) { + Object childNode = model.getChild( node, i ); + if ( childNode != peekNode ) { + super.addSelectionPaths( new TreePath[]{temp.pathByAddingChild( childNode )} ); + } + } + } + super.removeSelectionPaths( new TreePath[]{parent} ); + } + + + public Enumeration getAllSelectedPaths() { + Enumeration result = null; + + TreePath[] treePaths = getSelectionPaths(); + if ( treePaths == null ) { + List pathsList = Collections.emptyList(); + result = Collections.enumeration( pathsList ); + } + else { + List pathsList = Arrays.asList( treePaths ); + result = Collections.enumeration( pathsList ); + if ( dig ) { + result = new PreorderEnumeration( result, model ); + } + } + + return result; + } +} diff --git a/src/main/java/net/vhati/modmanager/ui/tree/ChildrenEnumeration.java b/src/main/java/net/vhati/modmanager/ui/tree/ChildrenEnumeration.java new file mode 100644 index 0000000..95f7d3b --- /dev/null +++ b/src/main/java/net/vhati/modmanager/ui/tree/ChildrenEnumeration.java @@ -0,0 +1,57 @@ +/** + * Based on ChildrenEnumeration (rev 120, 2007-07-20) + * By Santhosh Kumar T + * https://java.net/projects/myswing + * + * https://java.net/projects/myswing/sources/svn/content/trunk/src/skt/swing/tree/ChildrenEnumeration.java?rev=120 + */ + +/** + * MySwing: Advanced Swing Utilites + * Copyright (C) 2005 Santhosh Kumar T + *

+ * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + *

+ * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + */ + +package net.vhati.modmanager.ui.tree; + +import java.util.Enumeration; +import java.util.NoSuchElementException; +import javax.swing.tree.TreeModel; +import javax.swing.tree.TreePath; + + +public class ChildrenEnumeration implements Enumeration { + + private TreePath path; + private TreeModel model; + private int position = 0; + private int childCount; + + + public ChildrenEnumeration( TreePath path, TreeModel model ) { + this.path = path; + this.model = model; + childCount = model.getChildCount( path.getLastPathComponent() ); + } + + @Override + public boolean hasMoreElements() { + return position < childCount; + } + + @Override + public TreePath nextElement() { + if( !hasMoreElements() ) throw new NoSuchElementException(); + + return path.pathByAddingChild( model.getChild( path.getLastPathComponent(), position++ ) ); + } +} diff --git a/src/main/java/net/vhati/modmanager/ui/tree/PreorderEnumeration.java b/src/main/java/net/vhati/modmanager/ui/tree/PreorderEnumeration.java new file mode 100644 index 0000000..3e8fe9e --- /dev/null +++ b/src/main/java/net/vhati/modmanager/ui/tree/PreorderEnumeration.java @@ -0,0 +1,68 @@ +/** + * Based on PreorderEnumeration (rev 120, 2007-07-20) + * By Santhosh Kumar T + * https://java.net/projects/myswing + * + * https://java.net/projects/myswing/sources/svn/content/trunk/src/skt/swing/tree/PreorderEnumeration.java?rev=120 + */ + +/** + * MySwing: Advanced Swing Utilites + * Copyright (C) 2005 Santhosh Kumar T + *

+ * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + *

+ * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + */ + +package net.vhati.modmanager.ui.tree; + +import java.util.Collections; +import java.util.Enumeration; +import java.util.List; +import java.util.Stack; +import javax.swing.tree.TreeModel; +import javax.swing.tree.TreePath; + + +public class PreorderEnumeration implements Enumeration { + + private TreeModel model; + protected Stack> stack = new Stack>(); + + + public PreorderEnumeration( TreePath path, TreeModel model ) { + this( Collections.enumeration( Collections.singletonList( path ) ), model ); + } + + public PreorderEnumeration( Enumeration enumer, TreeModel model ){ + this.model = model; + stack.push( enumer ); + } + + + @Override + public boolean hasMoreElements() { + return ( !stack.empty() && stack.peek().hasMoreElements() ); + } + + @Override + public TreePath nextElement() { + Enumeration enumer = stack.peek(); + TreePath path = enumer.nextElement(); + + if ( !enumer.hasMoreElements() ) stack.pop(); + + if ( model.getChildCount( path.getLastPathComponent() ) > 0 ) { + stack.push( new ChildrenEnumeration( path, model ) ); + } + + return path; + } +} diff --git a/src/main/java/net/vhati/modmanager/ui/tree/TreeState.java b/src/main/java/net/vhati/modmanager/ui/tree/TreeState.java new file mode 100644 index 0000000..580d1c9 --- /dev/null +++ b/src/main/java/net/vhati/modmanager/ui/tree/TreeState.java @@ -0,0 +1,190 @@ +package net.vhati.modmanager.ui.tree; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + + +/** + * An implementation-agnostic model to pass between the GUI thread and the + * (de)serializer. + */ +public class TreeState { + + protected TreeNodeState rootNodeState = null; + + + public TreeState() { + } + + + public void setRootNodeState( TreeNodeState rootNodeState ) { + this.rootNodeState = rootNodeState; + } + + public TreeNodeState getRootNodeState() { + return rootNodeState; + } + + + public List findNodeStates( TreeNodeStateFilter filter ) { + return findNodeStates( getRootNodeState(), filter ); + } + + /** + * Returns a list of descendant node states which match a given filter. + */ + public List findNodeStates( TreeNodeState currentNodeState, TreeNodeStateFilter filter ) { + List results = new ArrayList( 1 ); + collectNodeStates( currentNodeState, filter, results ); + return results; + } + + public boolean collectNodeStates( TreeNodeState currentNodeState, TreeNodeStateFilter filter, List results ) { + int maxResultCount = filter.getMaxResultCount(); + boolean found = false; + + if ( filter.accept( currentNodeState ) ) { + results.add( currentNodeState ); + if ( maxResultCount > 0 && maxResultCount >= results.size() ) return true; + } + + if ( currentNodeState.getAllowsChildren() ) { + for ( Iterator it = currentNodeState.children(); it.hasNext(); ) { + TreeNodeState childNodeState = it.next(); + found = collectNodeStates( childNodeState, filter, results ); + if ( found && maxResultCount > 0 && maxResultCount >= results.size() ) return true; + } + } + + return found; + } + + + public boolean containsUserObject( Object o ) { + UserObjectTreeNodeStateFilter filter = new UserObjectTreeNodeStateFilter( o ); + filter.setMaxResultCount( 1 ); + List results = findNodeStates( filter ); + + return ( !results.isEmpty() ); + } + + + + public static interface TreeNodeStateFilter { + public int getMaxResultCount(); + public boolean accept( TreeNodeState nodeState ); + } + + + + public static class UserObjectTreeNodeStateFilter implements TreeNodeStateFilter { + + private Class objectClass = null; + private Object o = null; + private int maxResultCount = 0; + + + /** + * Constructs a filter matching objects of a given class (or subclass). + */ + public UserObjectTreeNodeStateFilter( Class objectClass ) { + this.objectClass = objectClass; + } + + /** + * Constructs a filter matching objects equal to a given object. + */ + public UserObjectTreeNodeStateFilter( Object o ) { + this.o = o; + } + + + public void setMaxResultCount( int n ) { maxResultCount = n; } + + @Override + public int getMaxResultCount() { return maxResultCount; } + + @Override + public boolean accept( TreeNodeState nodeState ) { + Object nodeObject = nodeState.getUserObject(); + if ( objectClass != null && nodeObject != null ) { + @SuppressWarnings("unchecked") + boolean result = objectClass.isAssignableFrom( nodeObject.getClass() ); + return result; + } + else if ( o != null ) { + return ( o.equals( nodeState.getUserObject() ) ); + } + return false; + } + } + + + public static class TreeNodeState { + + protected Object userObject = null; + protected boolean expand = false; + protected List children = null; + private TreeNodeState parentNodeState = null; + + + public TreeNodeState() { + this( false, false ); + } + + public TreeNodeState( boolean allowsChildren, boolean expand ) { + if ( allowsChildren ) { + this.expand = expand; + children = new ArrayList(); + } + } + + + /** + * Sets this node's parent to newParent but does not change the + * parent's child array. + */ + public void setParent( TreeNodeState nodeState ) { + parentNodeState = nodeState; + } + + public TreeNodeState getParent() { + return parentNodeState; + } + + + public boolean getAllowsChildren() { + return ( children != null ); + } + + public void addChild( TreeNodeState childNodeState ) { + TreeNodeState oldParent = childNodeState.getParent(); + if ( oldParent != null ) oldParent.removeChild( childNodeState ); + + childNodeState.setParent( this ); + children.add( childNodeState ); + } + + public void removeChild( TreeNodeState childNodeState ) { + children.remove( childNodeState ); + childNodeState.setParent( null ); + } + + /** + * Returns an iterator over this node state's children. + */ + public Iterator children() { + return children.iterator(); + } + + + public void setUserObject( Object userObject ) { + this.userObject = userObject; + } + + public Object getUserObject() { + return userObject; + } + } +} diff --git a/src/main/java/net/vhati/modmanager/ui/tree/TreeTransferHandler.java b/src/main/java/net/vhati/modmanager/ui/tree/TreeTransferHandler.java new file mode 100644 index 0000000..66c9bd3 --- /dev/null +++ b/src/main/java/net/vhati/modmanager/ui/tree/TreeTransferHandler.java @@ -0,0 +1,268 @@ +package net.vhati.modmanager.ui.tree; + +import java.awt.Cursor; +import java.awt.datatransfer.DataFlavor; +import java.awt.datatransfer.Transferable; +import java.awt.dnd.DragSource; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import javax.swing.JComponent; +import javax.swing.JTable; +import javax.swing.JTree; +import javax.swing.TransferHandler; +import javax.swing.tree.DefaultMutableTreeNode; +import javax.swing.tree.DefaultTreeModel; +import javax.swing.tree.MutableTreeNode; +import javax.swing.tree.TreeNode; +import javax.swing.tree.TreePath; + + +/** + * A handler to enable drag-and-drop within a JTree. + * + * When dropped, copies of highlighted nodes will be made via clone() and + * inserted at the drop location, then the originals will be removed. + * + * Dragging onto a space between nodes will insert at that location. + * Dragging onto a node that allows children will insert into it. + * Dragging onto a node that doesn't allow children will insert after it. + * + * The TreeModel must be DefaultTreeModel (or a subclass). + * All nodes must be DefaultMutableTreeNode (or a subclass) and properly + * implement Cloneable. + * Set the Jtree's DropMode to ON_OR_INSERT. + * The root node must be hidden, to prevent it from being dragged. + * The tree's selection model may be set to single or multiple. + */ +public class TreeTransferHandler extends TransferHandler { + + private DataFlavor localTreePathFlavor = null; + private JTree tree = null; + + + public TreeTransferHandler( JTree tree ) { + super(); + this.tree = tree; + + try { + localTreePathFlavor = new DataFlavor( DataFlavor.javaJVMLocalObjectMimeType + ";class=\""+ TreePath[].class.getName() +"\"" ); + } + catch ( ClassNotFoundException e ) { + //log.error( e ); + } + } + + @Override + protected Transferable createTransferable( JComponent c ) { + assert ( c == tree ); + TreePath[] highlightedPaths = tree.getSelectionPaths(); + + Map> pathsByLengthMap = new TreeMap>(); + for ( TreePath path : highlightedPaths ) { + if ( path.getPath().length == 1 ) continue; // Omit root node (shouldn't drag it anyway). + + Integer pathLength = new Integer( path.getPath().length ); + if ( !pathsByLengthMap.containsKey( pathLength ) ) { + pathsByLengthMap.put( pathLength, new ArrayList() ); + } + pathsByLengthMap.get( pathLength ).add( path ); + } + // For each length (shortest-first), iterate its paths. + // For each of those paths, search longer lengths' lists, + // removing any paths that are descendants of those short ancestor nodes. + List lengthsList = new ArrayList( pathsByLengthMap.keySet() ); + for ( int i=0; i < lengthsList.size(); i++ ) { + for ( TreePath ancestorPath : pathsByLengthMap.get( lengthsList.get( i ) ) ) { + for ( int j=i+1; j < lengthsList.size(); j++ ) { + + List childPaths = pathsByLengthMap.get( lengthsList.get( j ) ); + for ( Iterator childIt = childPaths.iterator(); childIt.hasNext(); ) { + TreePath childPath = childIt.next(); + if ( ancestorPath.isDescendant( childPath ) ) { + childIt.remove(); + } + } + + } + } + } + List uniquePathList = new ArrayList(); + for ( List paths : pathsByLengthMap.values() ) { + uniquePathList.addAll( paths ); + } + TreePath[] uniquePathsArray = uniquePathList.toArray( new TreePath[uniquePathList.size()] ); + + return new TreePathTransferrable( uniquePathsArray ); + } + + @Override + public boolean canImport( TransferHandler.TransferSupport ts ) { + boolean b = ( ts.getComponent() == tree && ts.isDrop() && ts.isDataFlavorSupported( localTreePathFlavor ) ); + tree.setCursor( b ? DragSource.DefaultMoveDrop : DragSource.DefaultMoveNoDrop ); + return b; + } + + @Override + public int getSourceActions( JComponent comp ) { + return TransferHandler.MOVE; + } + + @Override + @SuppressWarnings("Unchecked") + public boolean importData( TransferHandler.TransferSupport ts ) { + if ( !canImport(ts) ) return false; + + JTree dstTree = (JTree)ts.getComponent(); + DefaultTreeModel dstTreeModel = (DefaultTreeModel)dstTree.getModel(); + JTree.DropLocation dl = (JTree.DropLocation)ts.getDropLocation(); + TreePath dropPath = dl.getPath(); // Dest parent node, or null. + int dropIndex = dl.getChildIndex(); // Insertion child index in the dest parent node, + // or -1 if dropped onto a group. + + dstTree.setCursor( Cursor.getPredefinedCursor(Cursor.DEFAULT_CURSOR) ); + if ( dropPath == null ) return false; + MutableTreeNode dropParentNode = (MutableTreeNode)dropPath.getLastPathComponent(); + + // When dropping onto a non-group node, insert into the position after it instead. + if ( !dropParentNode.getAllowsChildren() ) { + MutableTreeNode prevParentNode = dropParentNode; + dropPath = dropPath.getParentPath(); + dropParentNode = (MutableTreeNode)dropPath.getLastPathComponent(); + dropIndex = dropParentNode.getIndex( prevParentNode ) + 1; + } + + try { + TreePath[] draggedPaths = (TreePath[])ts.getTransferable().getTransferData( localTreePathFlavor ); + + // Bail if the dropPath was among those dragged. + boolean badDrop = false; + for ( TreePath path : draggedPaths ) { + if ( path.equals( dropPath ) ) { + badDrop = true; + break; + } + } + + if ( !badDrop && dropParentNode.getAllowsChildren() ) { + for ( TreePath path : draggedPaths ) { + // Copy the dragged node and any children. + DefaultMutableTreeNode srcNode = (DefaultMutableTreeNode)path.getLastPathComponent(); + MutableTreeNode newNode = (MutableTreeNode)cloneNodes( srcNode ); + + if ( dropIndex != -1 ) { + // Insert. + dropParentNode.insert( newNode, dropIndex ); + dstTreeModel.nodesWereInserted( dropParentNode, new int[]{dropIndex} ); + dropIndex++; // Next insertion will be after this node. + } + else { + // Add to the end. + int addIndex = dropParentNode.getChildCount(); + dropParentNode.insert( newNode, addIndex ); + dstTreeModel.nodesWereInserted( dropParentNode, new int[]{addIndex} ); + if ( !dstTree.isExpanded( dropPath ) ) dstTree.expandPath( dropPath ); + } + } + return true; + } + } + catch ( Exception e ) { + // UnsupportedFlavorException: if Transferable.getTransferData() fails. + // IOException: if Transferable.getTransferData() fails. + // IllegalStateException: if insert/add fails because dropPath's node doesn't allow children. + //log.error( e ); + } + return false; + } + + @Override + protected void exportDone( JComponent source, Transferable data, int action ) { + if ( action == TransferHandler.MOVE || action == TransferHandler.NONE ) { + tree.setCursor( Cursor.getPredefinedCursor( Cursor.DEFAULT_CURSOR ) ); + } + + JTree srcTree = (JTree)source; + DefaultTreeModel srcTreeModel = (DefaultTreeModel)srcTree.getModel(); + + if ( action == TransferHandler.MOVE ) { + // Remove original dragged rows now that the move completed. + + try { + TreePath[] draggedPaths = (TreePath[])data.getTransferData( localTreePathFlavor ); + for ( TreePath path : draggedPaths ) { + MutableTreeNode doomedNode = (MutableTreeNode)path.getLastPathComponent(); + TreeNode parentNode = doomedNode.getParent(); + int doomedIndex = parentNode.getIndex( doomedNode ); + doomedNode.removeFromParent(); + srcTreeModel.nodesWereRemoved( parentNode, new int[]{doomedIndex}, new Object[]{doomedNode} ); + } + } + catch ( Exception e ) { + //log.error( e ); + } + } + } + + + /** + * Recursively clones a node and its descendants. + * + * The clone() methods will generally do a shallow copy, sharing + * userObjects. + * + * Sidenote: The parameter couldn't just be MutableTreeNode, because that + * doesn't offer the clone() method. And blindly using reflection to + * invoke it wouldn't be pretty. Conceivably, a settable factory could be + * designed to copy specific custom classes (using constructors instead + * of clone(). But that'd be overkill. + */ + @SuppressWarnings("Unchecked") + protected MutableTreeNode cloneNodes( DefaultMutableTreeNode srcNode ) { + MutableTreeNode resultNode = (MutableTreeNode)srcNode.clone(); + + Enumeration enumer = srcNode.children(); + while ( enumer.hasMoreElements() ) { + DefaultMutableTreeNode childNode = (DefaultMutableTreeNode)enumer.nextElement(); + int addIndex = resultNode.getChildCount(); + resultNode.insert( cloneNodes( (DefaultMutableTreeNode)childNode ), addIndex ); + } + + return resultNode; + } + + + /** + * Drag and drop TreePath data, constructed with a raw object + * from a drag source, to be transformed into a flavor + * suitable for the drop target. + */ + private class TreePathTransferrable implements Transferable { + private TreePath[] data; + + public TreePathTransferrable( TreePath[] data ) { + this.data = data; + } + + @Override + public Object getTransferData( DataFlavor flavor ) { + if ( flavor.equals( localTreePathFlavor ) ) { + return data; + } + return null; + } + + @Override + public DataFlavor[] getTransferDataFlavors() { + return new DataFlavor[] {localTreePathFlavor}; + } + + @Override + public boolean isDataFlavorSupported( DataFlavor flavor ) { + return flavor.equals( localTreePathFlavor ); + } + } +} diff --git a/src/main/java/net/vhati/modmanager/ui/tree/TristateButtonModel.java b/src/main/java/net/vhati/modmanager/ui/tree/TristateButtonModel.java new file mode 100644 index 0000000..4817243 --- /dev/null +++ b/src/main/java/net/vhati/modmanager/ui/tree/TristateButtonModel.java @@ -0,0 +1,105 @@ +/** + * Copied from "TristateCheckBox Revisited" (2007-05-25) + * By Dr. Heinz M. Kabutz + * http://www.javaspecialists.co.za/archive/Issue145.html + */ + +package net.vhati.modmanager.ui.tree; + +import java.awt.event.ItemEvent; +import javax.swing.JToggleButton.ToggleButtonModel; + + +public class TristateButtonModel extends ToggleButtonModel { + + private TristateState state = TristateState.DESELECTED; + + + public TristateButtonModel( TristateState state ) { + setState( state ); + } + + public TristateButtonModel() { + this( TristateState.DESELECTED ); + } + + + public void setIndeterminate() { + setState( TristateState.INDETERMINATE ); + } + + public boolean isIndeterminate() { + return ( state == TristateState.INDETERMINATE ); + } + + + @Override + public void setEnabled( boolean enabled ) { + super.setEnabled(enabled); + // Restore state display. + displayState(); + } + + @Override + public void setSelected( boolean selected ) { + setState( selected ? TristateState.SELECTED : TristateState.DESELECTED ); + } + + @Override + public void setArmed( boolean b ) { + } + + @Override + public void setPressed( boolean b ) { + } + + + public void iterateState() { + setState( state.next() ); + } + + public void setState( TristateState state ) { + this.state = state; + displayState(); + if ( state == TristateState.INDETERMINATE && isEnabled() ) { + // Send ChangeEvent. + fireStateChanged(); + + // Send ItemEvent. + int indeterminate = 3; + fireItemStateChanged(new ItemEvent( this, ItemEvent.ITEM_STATE_CHANGED, this, indeterminate )); + } + } + + private void displayState() { + super.setSelected( state != TristateState.DESELECTED ); + super.setArmed( state == TristateState.INDETERMINATE ); + super.setPressed( state == TristateState.INDETERMINATE ); + } + + public TristateState getState() { + return state; + } + + + + public static enum TristateState { + SELECTED { + public TristateState next() { + return INDETERMINATE; + } + }, + INDETERMINATE { + public TristateState next() { + return DESELECTED; + } + }, + DESELECTED { + public TristateState next() { + return SELECTED; + } + }; + + public abstract TristateState next(); + } +} diff --git a/src/main/java/net/vhati/modmanager/ui/tree/TristateCheckBox.java b/src/main/java/net/vhati/modmanager/ui/tree/TristateCheckBox.java new file mode 100644 index 0000000..95a4395 --- /dev/null +++ b/src/main/java/net/vhati/modmanager/ui/tree/TristateCheckBox.java @@ -0,0 +1,142 @@ +/* + * Based on "TristateCheckBox Revisited" (2007-05-25) + * By Dr. Heinz M. Kabutz + * http://www.javaspecialists.co.za/archive/Issue145.html + */ + +package net.vhati.modmanager.ui.tree; + +import java.awt.AWTEvent; +import java.awt.EventQueue; +import java.awt.event.ActionEvent; +import java.awt.event.InputEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.event.MouseListener; +import javax.swing.AbstractAction; +import javax.swing.ActionMap; +import javax.swing.ButtonModel; +import javax.swing.Icon; +import javax.swing.JCheckBox; +import javax.swing.SwingUtilities; +import javax.swing.event.ChangeEvent; +import javax.swing.event.ChangeListener; +import javax.swing.plaf.ActionMapUIResource; + +import net.vhati.modmanager.ui.tree.TristateButtonModel; +import net.vhati.modmanager.ui.tree.TristateButtonModel.TristateState; + + +public class TristateCheckBox extends JCheckBox { + + private final ChangeListener enableListener; + + + public TristateCheckBox( String text, Icon icon, TristateState initial ) { + super( text, icon ); + + setModel( new TristateButtonModel( initial ) ); + + enableListener = new ChangeListener() { + @Override + public void stateChanged( ChangeEvent e ) { + TristateCheckBox.this.setFocusable( TristateCheckBox.this.getModel().isEnabled() ); + } + }; + + // Add a listener for when the mouse is pressed. + super.addMouseListener(new MouseAdapter() { + @Override + public void mousePressed( MouseEvent e ) { + TristateCheckBox.this.iterateState(); + } + }); + + // Reset the keyboard action map. + ActionMap map = new ActionMapUIResource(); + map.put( "pressed", new AbstractAction() { + @Override + public void actionPerformed( ActionEvent e ) { + TristateCheckBox.this.iterateState(); + } + }); + map.put( "released", null ); + SwingUtilities.replaceUIActionMap( this, map ); + } + + public TristateCheckBox( String text, TristateState initial ) { + this( text, null, initial ); + } + + public TristateCheckBox( String text ) { + this( text, null ); + } + + public TristateCheckBox() { + this( null ); + } + + + public void setIndeterminate() { + getTristateModel().setIndeterminate(); + } + + public boolean isIndeterminate() { + return getTristateModel().isIndeterminate(); + } + + + public void setState( TristateState state ) { + getTristateModel().setState( state ); + } + + public TristateState getState() { + return getTristateModel().getState(); + } + + + @Override + public void setModel( ButtonModel newModel ) { + super.setModel( newModel ); + + // Listen for enable changes. + if ( model instanceof TristateButtonModel ) { + model.addChangeListener( enableListener ); + } + } + + @SuppressWarnings("unchecked") + public TristateButtonModel getTristateModel() { + return (TristateButtonModel)super.getModel(); + } + + + /** + * No one may add mouse listeners, not even Swing! + */ + @Override + public void addMouseListener( MouseListener l ) { + } + + + private void iterateState() { + // Maybe do nothing at all? + if ( !super.getModel().isEnabled() ) return; + + this.grabFocus(); + + // Iterate state. + getTristateModel().iterateState(); + + // Fire ActionEvent. + int modifiers = 0; + AWTEvent currentEvent = EventQueue.getCurrentEvent(); + if ( currentEvent instanceof InputEvent ) { + modifiers = ((InputEvent)currentEvent).getModifiers(); + } + else if ( currentEvent instanceof ActionEvent ) { + modifiers = ((ActionEvent)currentEvent).getModifiers(); + } + fireActionPerformed(new ActionEvent( this, ActionEvent.ACTION_PERFORMED, this.getText(), System.currentTimeMillis(), modifiers )); + } +}