diff --git a/vassal-app/src/main/java/VASSAL/launch/AbstractLaunchAction.java b/vassal-app/src/main/java/VASSAL/launch/AbstractLaunchAction.java index 010b77fd2b..4b61341762 100644 --- a/vassal-app/src/main/java/VASSAL/launch/AbstractLaunchAction.java +++ b/vassal-app/src/main/java/VASSAL/launch/AbstractLaunchAction.java @@ -39,6 +39,7 @@ import VASSAL.tools.io.ProcessWrapper; import VASSAL.tools.lang.MemoryUtils; import org.apache.commons.codec.digest.DigestUtils; +import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.SystemUtils; import org.apache.commons.lang3.tuple.Pair; import org.slf4j.Logger; @@ -47,6 +48,7 @@ import javax.swing.AbstractAction; import javax.swing.JOptionPane; import javax.swing.SwingWorker; +import javax.swing.UIManager; import java.awt.Dimension; import java.awt.Window; import java.awt.event.ActionEvent; @@ -572,6 +574,22 @@ private List buildArgumentList(String moduleName) { result.add("-cp"); //NON-NLS result.add(System.getProperty("java.class.path")); + // Specify the look and feel. + String defaultLaf = System.getProperty("swing.defaultlaf"); + if (defaultLaf == null) { + // On Windows the defaultlaf property is typically null for jre provided LaF. + // Get the current look and feel from the UIManager. + defaultLaf = UIManager.getLookAndFeel().getClass().getName(); + } + if (!StringUtils.isBlank(defaultLaf)) { + result.add("-Dswing.defaultlaf=" + defaultLaf); //NON-NLS + } + + final String lafFilePath = System.getProperty("look.feel"); + if (!StringUtils.isBlank(lafFilePath)) { + result.add("-Dlook.feel=" + lafFilePath); //NON-NLS + } + if (SystemUtils.IS_OS_MAC) { // set the MacOS dock parameters diff --git a/vassal-app/src/main/java/VASSAL/launch/ModuleManagerWindow.java b/vassal-app/src/main/java/VASSAL/launch/ModuleManagerWindow.java index 35188f8d88..dbf57fd881 100644 --- a/vassal-app/src/main/java/VASSAL/launch/ModuleManagerWindow.java +++ b/vassal-app/src/main/java/VASSAL/launch/ModuleManagerWindow.java @@ -90,6 +90,7 @@ import javax.swing.event.TreeExpansionEvent; import javax.swing.event.TreeWillExpandListener; import javax.swing.table.DefaultTableCellRenderer; +import javax.swing.table.TableCellRenderer; import javax.swing.table.TableColumn; import javax.swing.table.TableColumnModel; import javax.swing.text.DefaultEditorKit; @@ -1018,6 +1019,30 @@ public void actionPerformed(ActionEvent ae) { }); } + /** + * Set the foreground color of all table cells in the row. + * @param renderer the TableCellRenderer to prepare + * @param row the row of the cell to render, where 0 is the first row + * @param column the column of the cell to render, + * where 0 is the first column + */ + @Override + public Component prepareRenderer(TableCellRenderer renderer, int row, int column) { + final Component component = super.prepareRenderer(renderer, row, column); + + final MyTree tree = getInstance().tree; + final TreePath path = tree.getPathForRow(row); + if (path != null) { + final MyTreeNode node = (MyTreeNode) path.getLastPathComponent(); + final AbstractInfo info = node.getNodeInfo(); + final Color c = info.getTreeCellFgColor(); + if (c != null) { + component.setForeground(c); + } + } + return component; + } + // FIXME: Where's the rest of the comment??? /** * There appears to be a bug/strange interaction between JXTreetable and the ComponentSplitter @@ -1035,7 +1060,6 @@ public String getToolTipText(MouseEvent event) { * - Add file name as tooltip * - Handle expanded display (some nodes use the same icon for expanded/unexpanded) * - Gray out inactive extensions - * - Gray out Save Games that belong to other modules */ private static class MyTreeCellRenderer extends DefaultTreeCellRenderer { private static final long serialVersionUID = 1L; @@ -1050,7 +1074,10 @@ public Component getTreeCellRendererComponent( setText(info.toString()); setToolTipText(info.getToolTipText()); setIcon(info.getIcon(expanded)); - setForeground(info.getTreeCellFgColor()); + final Color c = info.getTreeCellFgColor(); + if (c != null) { + setForeground(c); + } return this; } } @@ -1324,7 +1351,7 @@ public void setTreeNode(MyTreeNode n) { * @return cell text color */ public Color getTreeCellFgColor() { - return Color.black; + return null; } /** @@ -1695,7 +1722,10 @@ public String getSortKey() { @Override public Color getTreeCellFgColor() { - return Info.isModuleTooNew(getVassalVersion()) ? Color.GRAY : Color.BLACK; + if (Info.isModuleTooNew(getVassalVersion())) { + return UIManager.getColor("TextPane.inactiveForeground"); // Color.gray + } + return null; } } @@ -1807,13 +1837,20 @@ public JPopupMenu buildPopup(int row) { @Override public Color getTreeCellFgColor() { - // FIXME: should get colors from LAF if (isActive()) { - return metadata == null ? Color.red : Color.black; + if (metadata == null) { + return Color.red; + } } else { - return metadata == null ? Color.pink : Color.gray; + if (metadata == null) { + return Color.pink; + } + else { + return UIManager.getColor("TextPane.inactiveForeground"); // Color.gray + } } + return null; // default color } @Override @@ -2051,8 +2088,12 @@ private boolean belongsToModule() { @Override public Color getTreeCellFgColor() { - // FIXME: should get colors from LAF - return belongsToModule() ? Color.black : Color.gray; + if (!belongsToModule()) { + return UIManager.getColor("TextPane.inactiveForeground"); // Color.gray; + } + else { + return null; + } } @Override diff --git a/vassal-app/src/main/java/VASSAL/launch/StartUp.java b/vassal-app/src/main/java/VASSAL/launch/StartUp.java index 16b7ac2829..051327fa98 100644 --- a/vassal-app/src/main/java/VASSAL/launch/StartUp.java +++ b/vassal-app/src/main/java/VASSAL/launch/StartUp.java @@ -21,13 +21,26 @@ import java.awt.AWTError; import java.awt.Font; import java.awt.Toolkit; +import java.io.IOException; +import java.io.InputStream; import java.lang.reflect.InvocationTargetException; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import javax.swing.UIManager; import javax.swing.SwingUtilities; import javax.swing.UnsupportedLookAndFeelException; +import javax.swing.JOptionPane; +import javax.swing.LookAndFeel; +import VASSAL.i18n.Resources; import VASSAL.preferences.Prefs; import VASSAL.preferences.ReadOnlyPrefs; +import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.SystemUtils; import org.slf4j.Logger; @@ -83,6 +96,22 @@ public void setUIFont(javax.swing.plaf.FontUIResource f, javax.swing.plaf.FontUI } } + /** + * Helper object to store Look and Feel loader errors. + * Resource string lookup is not possible until the UIManager loads. + * The object stores a resource ID and the associated exception + * for display after the UI is functional. + */ + protected static class LafLoadError { + protected String id; + protected Exception exception; + + public LafLoadError(String id, Exception e) { + this.id = id; + exception = e; + } + } + protected void initUIProperties() { System.setProperty("swing.aatext", "true"); //$NON-NLS-1$ //$NON-NLS-2$ System.setProperty("swing.boldMetal", "false"); //$NON-NLS-1$ //$NON-NLS-2$ @@ -115,19 +144,45 @@ protected void initUIProperties() { ); } - if (!SystemUtils.IS_OS_WINDOWS) { - // use native LookAndFeel - // NB: This must be after Mac-specific properties + LafLoadError lafClassLoadError = null; + String defaultLaf = System.getProperty("swing.defaultlaf"); // NON-NLS + if (defaultLaf != null) { try { - UIManager.setLookAndFeel( - UIManager.getSystemLookAndFeelClassName() - ); - } - catch (ClassNotFoundException | UnsupportedLookAndFeelException - | InstantiationException | IllegalAccessException e) { - ErrorDialog.bug(e); + // Check that the look and feel is in the class path. + Class.forName(defaultLaf, false, getClass().getClassLoader()); } + catch (ClassNotFoundException e) { + // Attempt to dynamically load the look and feel class. + final String lookAndFeelPath = System.getProperty("look.feel"); // NON-NLS + if (!StringUtils.isBlank(lookAndFeelPath)) { + lafClassLoadError = installLookAndFeel(defaultLaf, lookAndFeelPath); + } + else { + // The desired Look and Feel is not available. + // Record the error and report it later once the UIManager is up. + lafClassLoadError = new LafLoadError("Startup.laf_no_path", e); // NON-NLS + } + if (lafClassLoadError != null) { + defaultLaf = null; + System.clearProperty("swing.defaultlaf"); // NON-NLS + } + } + } + if (!SystemUtils.IS_OS_WINDOWS) { + if (defaultLaf == null) { + // use native LookAndFeel + // NB: This must be after Mac-specific properties + try { + UIManager.setLookAndFeel( + UIManager.getSystemLookAndFeelClassName() + ); + } + catch (ClassNotFoundException | UnsupportedLookAndFeelException + | InstantiationException | IllegalAccessException e) { + ErrorDialog.bug(e); + } + } // The GTK LaF has a color picker which lacks the ability to // select transparency. We can override that and it doesn't // look too goofy. @@ -159,6 +214,13 @@ protected void initUIProperties() { catch (NumberFormatException e) { // No action, keep default system/java/whatever fonts. } + // Report any Look and Feel load errors. + if (lafClassLoadError != null) { + JOptionPane.showMessageDialog(null, + Resources.getString(lafClassLoadError.id, lafClassLoadError.exception.getMessage()), + Resources.getString("Startup.laf_default"), // NON-NLS + JOptionPane.WARNING_MESSAGE); + } }); } catch (InterruptedException | InvocationTargetException e) { @@ -166,6 +228,74 @@ protected void initUIProperties() { } } + /** + * Install and set the look and feel. + * @param defaultLaf The name of the class that implements this look and feel. + * @param lookAndFeelPath The name of the (jar) file containing the look and feel class. + * This can be absolute or relative path. + * @return Return null on success otherwise returns an error object. + */ + protected LafLoadError installLookAndFeel(String defaultLaf, String lookAndFeelPath) { + LafLoadError loadError = null; + final String separator = SystemUtils.IS_OS_WINDOWS ? ";" : ":"; // NON-NLS + final String[] lafPaths = lookAndFeelPath.split(separator); + final URL[] urls = new URL[lafPaths.length]; + final Path root = Paths.get(String.valueOf(Info.getBaseDir())); + try { + int index = 0; + for (final String lafPath : lafPaths) { + Path filePath = Paths.get(lafPath); + if (filePath.getRoot() == null) { + // Not an absolute path. Prepend the user dir. + filePath = root.resolve(filePath); + } + final URI u = filePath.toUri(); + urls[index++] = u.toURL(); + } + // Create an instance of URLClassloader using the URL. + final ClassLoader loader = URLClassLoader.newInstance(urls, getClass().getClassLoader()); + if (defaultLaf.endsWith(".theme.json")) { // NON-NLS + Path filePath = Paths.get(defaultLaf); + if (filePath.getRoot() == null) { + // Convert to an absolute path. + filePath = root.resolve(filePath); + } + + final InputStream inputStream = Files.newInputStream(filePath); + final Class IntelliJThemeLoader = Class.forName("com.formdev.flatlaf.IntelliJTheme", true, loader); // NON-NLS + IntelliJThemeLoader.getMethod("setup", InputStream.class).invoke(null, inputStream); // NON-NLS + } + else { + final Class lookAndFeelClass = Class.forName(defaultLaf, true, loader); + final LookAndFeel lookAndFeel = (LookAndFeel) lookAndFeelClass.getConstructors()[0].newInstance(); + + UIManager.setLookAndFeel(lookAndFeel); + UIManager.installLookAndFeel(lookAndFeel.getName(), lookAndFeelClass.getName()); + } + return null; // Success + } + catch (ClassNotFoundException | InstantiationException | IllegalAccessException | InvocationTargetException ex) { + // ClassNotFound source: Class.forName() + // Instantiation source: newInstance() + // IllegalAccess or InvocationTarget sources: invoke() or newInstance() + loadError = new LafLoadError("Startup.laf_not_found", ex); // NON-NLS + } + catch (UnsupportedLookAndFeelException ex) { + // Source: setLookAndFeel() + loadError = new LafLoadError("Startup.laf_unsupported", ex); // NON-NLS + } + catch (MalformedURLException | NoSuchMethodException ex) { + // NoSuchMethod source: getMethod() + // MalformedURL source: Files.newInputStream() or toURL() + loadError = new LafLoadError("Startup.laf_malformed", ex); // NON-NLS + } + catch (IOException ex) { + // Source: Files.newInputStream() + loadError = new LafLoadError("Error.file_read_error_message", ex); // NON-NLS + } + return loadError; + } + protected void initSystemSpecificProperties() {} public void startErrorLog() { diff --git a/vassal-app/src/main/resources/VASSAL/i18n/VASSAL.properties b/vassal-app/src/main/resources/VASSAL/i18n/VASSAL.properties index 1180e03059..b4f4e1cf07 100644 --- a/vassal-app/src/main/resources/VASSAL/i18n/VASSAL.properties +++ b/vassal-app/src/main/resources/VASSAL/i18n/VASSAL.properties @@ -1165,6 +1165,13 @@ ServerAddressBook.listening=Listen for Invites on Port %1$s ServerStatusView.4=html.disable +# Startup Look and Feel +Startup.laf_default=Loading the Default Look and Feel +Startup.laf_malformed=Invalid look and feel filename or path.\nCheck the -Dlook.feel= option.\n%1$s +Startup.laf_no_path=Cannot load the user specified Look and Feel.\nPossibly missing the -Dlook.feel option.\n%1$s +Startup.laf_not_found=Look and Feel not found or unloadable.\n%1$s +Startup.laf_unsupported=The selected Look and Feel is unsupported.\n%1$s + # Tiling Handler TilingHandler.processing_image_tiles=Processing Image Tiles # Item 1 is an image filename being "tiled" (broken up into small squares for faster load & scrolling)