package games.strategy.ui; import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; import java.awt.BorderLayout; import java.awt.Component; import java.awt.Frame; import java.awt.GridLayout; import java.awt.Window; import java.awt.event.ActionListener; import java.awt.event.KeyEvent; import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent; import java.io.File; import java.util.Arrays; import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.Set; import java.util.concurrent.Callable; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.function.Function; import java.util.stream.Collectors; import javax.swing.Action; import javax.swing.Box; import javax.swing.BoxLayout; import javax.swing.ButtonGroup; import javax.swing.DefaultListModel; import javax.swing.JButton; import javax.swing.JComponent; import javax.swing.JDialog; import javax.swing.JEditorPane; import javax.swing.JFileChooser; import javax.swing.JFrame; import javax.swing.JLabel; import javax.swing.JList; import javax.swing.JMenu; import javax.swing.JOptionPane; import javax.swing.JPanel; import javax.swing.JRadioButton; import javax.swing.JScrollPane; import javax.swing.JTabbedPane; import javax.swing.JTextArea; import javax.swing.KeyStroke; import javax.swing.SwingUtilities; import javax.swing.SwingWorker; import javax.swing.UIManager; import javax.swing.border.Border; import javax.swing.border.EmptyBorder; import javax.swing.filechooser.FileFilter; import javax.swing.filechooser.FileNameExtensionFilter; import com.google.common.annotations.VisibleForTesting; import games.strategy.engine.framework.startup.ui.MainFrame; import games.strategy.net.OpenFileUtility; import games.strategy.triplea.UrlConstants; public class SwingComponents { private static final String PERIOD = "."; public static JTabbedPane newJTabbedPane() { return new JTabbedPaneWithFixedWidthTabs(); } public static JPanel newJPanelWithVerticalBoxLayout() { return newJPanelWithBoxLayout(BoxLayout.Y_AXIS); } private static JPanel newJPanelWithBoxLayout(final int layout) { final JPanel panel = new JPanel(); panel.setLayout(new BoxLayout(panel, layout)); return panel; } public static JPanel newJPanelWithHorizontalBoxLayout() { return newJPanelWithBoxLayout(BoxLayout.X_AXIS); } /** * Returns a row that has some padding at the top of it, and bottom. */ public static JPanel createRowWithTopAndBottomPadding(final JPanel contentRow, final int topPadding, final int bottomPadding) { final JPanel rowContents = new JPanel(); rowContents.setLayout(new BoxLayout(rowContents, BoxLayout.Y_AXIS)); rowContents.add(Box.createVerticalStrut(topPadding)); rowContents.add(contentRow); rowContents.add(Box.createVerticalStrut(bottomPadding)); return rowContents; } public static ButtonGroup createButtonGroup(final JRadioButton... radioButtons) { final ButtonGroup group = new ButtonGroup(); for (final JRadioButton radioButton : Arrays.asList(radioButtons)) { group.add(radioButton); } return group; } public static JDialog newJDialog(String title) { JDialog dialog = new JDialog(MainFrame.getInstance(), title); dialog.setModal(false); return dialog; } public static class ModalJDialog extends JDialog { private static final long serialVersionUID = -3953716954531215173L; protected ModalJDialog() { super((Frame) null, true); setLocationByPlatform(true); } } public static void showWindow(final Window window) { window.pack(); window.setLocationByPlatform(true); window.setVisible(true); } public static JPanel newJPanelWithGridLayout(final int rows, final int columns) { final JPanel panel = new JPanel(); panel.setLayout(new GridLayout(rows, columns)); return panel; } public enum KeyboardCode { D(KeyEvent.VK_D), G(KeyEvent.VK_G); private final int keyEventCode; KeyboardCode(final int keyEventCode) { this.keyEventCode = keyEventCode; } int getSwingKeyEventCode() { return keyEventCode; } } private static final Set<String> visiblePrompts = new HashSet<>(); /** * Creates a JPanel with BorderLayout and adds a west component and an east component. */ public static JPanel horizontalJPanel(final Component westComponent, final Component eastComponent) { final JPanel panel = new JPanel(); panel.setLayout(new BorderLayout()); panel.add(westComponent, BorderLayout.WEST); panel.add(eastComponent, BorderLayout.EAST); return panel; } public static JPanel gridPanel(final int rows, final int cols) { final JPanel panel = new JPanel(); panel.setLayout(new GridLayout(rows, cols)); return panel; } public static JButton newJButton(final String title, final String toolTip, final Runnable actionListener) { return newJButton(title, toolTip, SwingAction.of(e -> actionListener.run())); } public static JButton newJButton(final String title, final String toolTip, final ActionListener actionListener) { final JButton button = newJButton(title, actionListener); button.setToolTipText(toolTip); return button; } public static JButton newJButton(final String title, final ActionListener actionListener) { final JButton button = new JButton(title); button.addActionListener(actionListener); return button; } public static JScrollPane newJScrollPane(final Component contents) { final JScrollPane scroll = new JScrollPane(); scroll.setViewportView(contents); scroll.setBorder(null); scroll.getViewport().setBorder(null); return scroll; } public static void promptUser(final String title, final String message, final Runnable confirmedAction) { boolean showMessage = false; synchronized (visiblePrompts) { if (!visiblePrompts.contains(message)) { visiblePrompts.add(message); showMessage = true; } } if (showMessage) { SwingUtilities.invokeLater(() -> { // blocks until the user responds to the modal dialog final int response = JOptionPane.showConfirmDialog(null, message, title, JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE); // dialog is now closed visiblePrompts.remove(message); if (response == JOptionPane.YES_OPTION) { confirmedAction.run(); } }); } } public static void newMessageDialog(final String msg) { SwingUtilities.invokeLater(() -> JOptionPane.showMessageDialog(null, msg)); } public static JFrame newJFrameWithCloseAction(final Runnable closeListener) { final JFrame frame = new JFrame(); addWindowCloseListener(frame, closeListener); return frame; } public static void addWindowCloseListener(final Window window, final Runnable closeAction) { window.addWindowListener(new WindowAdapter() { @Override public void windowClosing(final WindowEvent e) { closeAction.run(); } }); } public static <T> DefaultListModel<String> newJListModel(final List<T> maps, final Function<T, String> mapper) { final List<String> mapList = maps.stream().map(mapper).collect(Collectors.toList()); final DefaultListModel<String> model = new DefaultListModel<>(); mapList.forEach(model::addElement); return model; } public static JList<String> newJList(final DefaultListModel<String> listModel) { return new JList<>(listModel); } public static JEditorPane newHtmlJEditorPane() { final JEditorPane m_descriptionPane = new JEditorPane(); m_descriptionPane.setEditable(false); m_descriptionPane.setContentType("text/html"); m_descriptionPane.setBackground(new JLabel().getBackground()); return m_descriptionPane; } public static JPanel newBorderedPanel(final int borderWidth) { final JPanel panel = new JPanel(); panel.setLayout(new BorderLayout()); panel.setBorder(newEmptyBorder(borderWidth)); return panel; } public static Border newEmptyBorder(final int borderWidth) { return new EmptyBorder(borderWidth, borderWidth, borderWidth, borderWidth); } public static void newOpenUrlConfirmationDialog(final UrlConstants url) { newOpenUrlConfirmationDialog(url.toString()); } public static void newOpenUrlConfirmationDialog(final String url) { final String msg = "Okay to open URL in a web browser?\n" + url; SwingComponents.promptUser("Open external URL?", msg, () -> OpenFileUtility.openURL(url)); } public static void showDialog(final String title, final String message) { SwingUtilities.invokeLater(() -> JOptionPane.showMessageDialog(null, message, title, JOptionPane.INFORMATION_MESSAGE)); } public static JDialog newJDialogModal(final JFrame parent, final String title, final JPanel contents) { final JDialog dialog = new JDialog(parent, title, true); dialog.getContentPane().add(contents); final Action closeAction = SwingAction.of("", e -> dialog.setVisible(false)); final KeyStroke stroke = KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0); final String key = "dialog.close"; dialog.getRootPane().getActionMap().put(key, closeAction); dialog.getRootPane().getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(stroke, key); return dialog; } public static JMenu newJMenu(final String menuTitle, final KeyboardCode keyboardCode) { final JMenu menu = new JMenu(menuTitle); menu.setMnemonic(keyboardCode.getSwingKeyEventCode()); return menu; } /** * Creates a new component that emulates a multiline label. * * <p> * The multiline label will properly wrap text that has embedded newlines ({@code \n}). * </p> * * @param text The text to be displayed; may be {@code null}. * @param rows The number of rows; must be greater than or equal to zero. * @param cols The number of columns; must be greater than or equal to zero. * * @return The new multiline label; never {@code null}. * * @throws IllegalArgumentException If {@code rows} or {@code cols} is negative. */ public static JTextArea newMultilineLabel(final String text, final int rows, final int cols) { checkArgument(rows >= 0, "rows must not be negative"); checkArgument(cols >= 0, "cols must not be negative"); final JTextArea textArea = new JTextArea(text, rows, cols); textArea.setCursor(null); textArea.setEditable(false); textArea.setFocusable(false); textArea.setFont(UIManager.getFont("Label.font")); textArea.setLineWrap(true); textArea.setOpaque(false); textArea.setWrapStyleWord(true); return textArea; } /** * Displays a file chooser from which the user can select a file to save. * * <p> * The user will be asked to confirm the save if the selected file already exists. * </p> * * @param parent Determines the {@code Frame} in which the dialog is displayed; if {@code null}, or if {@code parent} * has no {@code Frame}, a default {@code Frame} is used. * @param fileExtension The extension of the file to save, with or without a leading period; must not be {@code null}. * This extension will be automatically appended to the file name if not present. * @param fileExtensionDescription The description of the file extension to be displayed in the file chooser; must not * be {@code null}. * * @return The file selected by the user or empty if the user aborted the save; never {@code null}. */ public static Optional<File> promptSaveFile(final Component parent, final String fileExtension, final String fileExtensionDescription) { checkNotNull(fileExtension); checkNotNull(fileExtensionDescription); final JFileChooser fileChooser = new JFileChooser() { private static final long serialVersionUID = -136588718021703367L; @Override public void approveSelection() { final File file = appendExtensionIfAbsent(getSelectedFile(), fileExtension); setSelectedFile(file); if (file.exists()) { final int result = JOptionPane.showConfirmDialog( parent, String.format("A file named \"%s\" already exists. Do you want to replace it?", file.getName()), "Confirm Save", JOptionPane.YES_NO_OPTION, JOptionPane.WARNING_MESSAGE); if (result != JOptionPane.YES_OPTION) { return; } } super.approveSelection(); } }; final String fileExtensionWithoutLeadingPeriod = extensionWithoutLeadingPeriod(fileExtension); final FileFilter fileFilter = new FileNameExtensionFilter( String.format("%s, *.%s", fileExtensionDescription, fileExtensionWithoutLeadingPeriod), fileExtensionWithoutLeadingPeriod); fileChooser.setFileFilter(fileFilter); final int result = fileChooser.showSaveDialog(parent); return (result == JFileChooser.APPROVE_OPTION) ? Optional.of(fileChooser.getSelectedFile()) : Optional.empty(); } @VisibleForTesting static File appendExtensionIfAbsent(final File file, final String extension) { final String extensionWithLeadingPeriod = extensionWithLeadingPeriod(extension); if (file.getName().toLowerCase().endsWith(extensionWithLeadingPeriod.toLowerCase())) { return file; } return new File(file.getParentFile(), file.getName() + extensionWithLeadingPeriod); } @VisibleForTesting static String extensionWithLeadingPeriod(final String extension) { return extension.isEmpty() || extension.startsWith(PERIOD) ? extension : PERIOD + extension; } @VisibleForTesting static String extensionWithoutLeadingPeriod(final String extension) { return extension.startsWith(PERIOD) ? extension.substring(PERIOD.length()) : extension; } /** * Runs the specified task on a background thread while displaying a progress dialog. * * @param<T> The type of the task result. * * @param frame The {@code Frame} from which the progress dialog is displayed or {@code null} to use a shared, hidden * frame as the owner of the progress dialog. * @param message The message to display in the progress dialog; must not be {@code null}. * @param task The task to be executed; must not be {@code null}. * * @return A promise that resolves to the result of the task; never {@code null}. */ public static <T> CompletableFuture<T> runWithProgressBar( final Frame frame, final String message, final Callable<T> task) { checkNotNull(message); checkNotNull(task); final CompletableFuture<T> promise = new CompletableFuture<>(); final SwingWorker<T, ?> worker = new SwingWorker<T, Void>() { @Override protected T doInBackground() throws Exception { return task.call(); } @Override protected void done() { try { promise.complete(get()); } catch (final ExecutionException e) { promise.completeExceptionally(e.getCause()); } catch (final InterruptedException e) { promise.completeExceptionally(e); Thread.currentThread().interrupt(); } } }; final ProgressDialog progressDialog = new ProgressDialog(frame, message); worker.addPropertyChangeListener(new SwingWorkerCompletionWaiter(progressDialog)); worker.execute(); return promise; } }