diff --git a/pom.xml b/pom.xml index 5658464..4835434 100644 --- a/pom.xml +++ b/pom.xml @@ -111,7 +111,7 @@ sign,deploy-to-scijava - 1.3.2 + 2.0.1 0.1 @@ -171,7 +171,7 @@ ${object-inspector.version} - net.sourceforge.jdatepicker + org.jdatepicker jdatepicker ${jdatepicker.version} diff --git a/src/main/java/org/scijava/ui/swing/AbstractSwingUI.java b/src/main/java/org/scijava/ui/swing/AbstractSwingUI.java index a1ee715..477f063 100644 --- a/src/main/java/org/scijava/ui/swing/AbstractSwingUI.java +++ b/src/main/java/org/scijava/ui/swing/AbstractSwingUI.java @@ -137,6 +137,20 @@ public SystemClipboard getSystemClipboard() { @Override public File chooseFile(final File file, final String style) { + return chooseFile((Component)null, file, style); + } + + /** + * Like {@link #chooseFile(File, String)}, but the dialog is shown relative + * to the given component. + * + * @param owner The component to center the dialog on. If {@code null}, the + * application frame is used. + */ + public File chooseFile(final Component owner, final File file, + final String style) + { + final Component parent = owner != null ? owner : appFrame; final File[] result = new File[1]; try { // NB: We show the JFileChooser on the EDT because otherwise there could @@ -148,10 +162,10 @@ public File chooseFile(final File file, final String style) { } final int rval; if (FileWidget.SAVE_STYLE.equals(style)) { - rval = chooser.showSaveDialog(appFrame); + rval = chooser.showSaveDialog(parent); } else { // default behavior - rval = chooser.showOpenDialog(appFrame); + rval = chooser.showOpenDialog(parent); } if (rval == JFileChooser.APPROVE_OPTION) { result[0] = chooser.getSelectedFile(); @@ -166,6 +180,20 @@ public File chooseFile(final File file, final String style) { @Override public File[] chooseFiles(final File parent, final File[] files, final FileFilter filter, final String style) { + return chooseFiles(null, parent, files, filter, style); + } + + /** + * Like {@link #chooseFiles(File, File[], FileFilter, String)}, but the + * dialog is shown relative to the given component. + * + * @param owner The component to center the dialog on. If {@code null}, the + * application frame is used. + */ + public File[] chooseFiles(final Component owner, final File parent, + final File[] files, final FileFilter filter, final String style) + { + final Component dialogParent = owner != null ? owner : appFrame; final File[][] result = new File[1][]; try { // NB: We show the JFileChooser on the EDT because otherwise there could @@ -202,7 +230,7 @@ public boolean accept(File f) { }; chooser.setFileFilter(fileFilter); } - int rval = chooser.showOpenDialog(appFrame); + int rval = chooser.showOpenDialog(dialogParent); if (rval == JFileChooser.APPROVE_OPTION) { result[0] = chooser.getSelectedFiles(); } diff --git a/src/main/java/org/scijava/ui/swing/commands/CommandFinderPanel.java b/src/main/java/org/scijava/ui/swing/commands/CommandFinderPanel.java index 5cf6f45..0d98bd1 100644 --- a/src/main/java/org/scijava/ui/swing/commands/CommandFinderPanel.java +++ b/src/main/java/org/scijava/ui/swing/commands/CommandFinderPanel.java @@ -60,6 +60,7 @@ import javax.swing.table.TableColumn; import javax.swing.table.TableColumnModel; +import com.formdev.flatlaf.FlatClientProperties; import net.miginfocom.swing.MigLayout; import org.scijava.MenuEntry; @@ -97,6 +98,7 @@ public CommandFinderPanel(final ModuleService moduleService, setPreferredSize(new Dimension(800, 600)); searchField = new JTextField(12); + searchField.putClientProperty(FlatClientProperties.TEXT_FIELD_SHOW_CLEAR_BUTTON, true); commandsList = new JTable(20, CommandTableModel.COLUMN_COUNT); commandsList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); commandsList.setRowSelectionAllowed(true); diff --git a/src/main/java/org/scijava/ui/swing/console/ConsolePanel.java b/src/main/java/org/scijava/ui/swing/console/ConsolePanel.java index 7279b1f..b96389b 100644 --- a/src/main/java/org/scijava/ui/swing/console/ConsolePanel.java +++ b/src/main/java/org/scijava/ui/swing/console/ConsolePanel.java @@ -137,7 +137,7 @@ private synchronized void initGui() { final FontMetrics fm = scrollPane.getFontMetrics(textPane.getFont()); final int charWidth = fm.charWidth('a'); final int lineHeight = fm.getHeight(); - scrollPane.setPreferredSize(new Dimension(charWidth * 80, lineHeight * 10)); //80 columns, 10 lines + scrollPane.setPreferredSize(new Dimension(charWidth * 60, lineHeight * 15)); //60 columns, 15 lines scrollPane.getHorizontalScrollBar().setUnitIncrement(charWidth); scrollPane.getVerticalScrollBar().setUnitIncrement(2 * lineHeight); textPane.setComponentPopupMenu(initMenu()); diff --git a/src/main/java/org/scijava/ui/swing/console/ItemTextPane.java b/src/main/java/org/scijava/ui/swing/console/ItemTextPane.java index 2bc0bdf..8ea1182 100644 --- a/src/main/java/org/scijava/ui/swing/console/ItemTextPane.java +++ b/src/main/java/org/scijava/ui/swing/console/ItemTextPane.java @@ -29,6 +29,7 @@ package org.scijava.ui.swing.console; +import java.awt.Color; import java.awt.Font; import java.util.Collections; import java.util.Iterator; @@ -37,6 +38,7 @@ import javax.swing.JPopupMenu; import javax.swing.JScrollPane; import javax.swing.JTextPane; +import javax.swing.UIManager; import javax.swing.text.AttributeSet; import javax.swing.text.BadLocationException; import javax.swing.text.DefaultStyledDocument; @@ -68,7 +70,18 @@ class ItemTextPane { private DocumentCalculator initialCalculator = null; - private JTextPane textPane = new JTextPane(); + private JTextPane textPane = new JTextPane() { + + @Override + public void updateUI() { + super.updateUI(); + // NB: FlatLaf paints non-editable text with a gray inactive background. + // Use the (plain, non-UIResource) tree background instead, so that the + // log matches the sources panel. Re-applied on look and feel changes + final Color bg = UIManager.getColor("Tree.background"); + if (bg != null) setBackground(new Color(bg.getRGB())); + } + }; private JScrollPane scrollPane = new JScrollPane(textPane); diff --git a/src/main/java/org/scijava/ui/swing/console/LogSourcesPanel.java b/src/main/java/org/scijava/ui/swing/console/LogSourcesPanel.java index 18b7112..c8d5a43 100644 --- a/src/main/java/org/scijava/ui/swing/console/LogSourcesPanel.java +++ b/src/main/java/org/scijava/ui/swing/console/LogSourcesPanel.java @@ -30,6 +30,7 @@ package org.scijava.ui.swing.console; import java.awt.BorderLayout; +import java.awt.Font; import java.awt.event.FocusEvent; import java.awt.event.FocusListener; import java.util.Arrays; @@ -47,21 +48,16 @@ import java.util.stream.Collectors; import java.util.stream.Stream; -import javax.swing.JButton; -import javax.swing.JComponent; -import javax.swing.JFrame; -import javax.swing.JLabel; -import javax.swing.JMenuItem; -import javax.swing.JPanel; -import javax.swing.JPopupMenu; -import javax.swing.JScrollPane; -import javax.swing.JTree; +import javax.swing.*; import javax.swing.event.TreeSelectionEvent; import javax.swing.tree.DefaultMutableTreeNode; import javax.swing.tree.DefaultTreeCellRenderer; import javax.swing.tree.DefaultTreeModel; import javax.swing.tree.TreePath; +import com.formdev.flatlaf.FlatClientProperties; +import com.formdev.flatlaf.FlatLaf; + import net.miginfocom.swing.MigLayout; import org.scijava.log.LogLevel; @@ -89,20 +85,31 @@ class LogSourcesPanel extends JPanel { private Predicate filter = message -> true; private Runnable changeListener = null; - private List selected; + private List selected = Collections.emptyList(); // -- constructor -- - LogSourcesPanel(JButton reloadButton) { + LogSourcesPanel(JButton reloadButton, Runnable toggleAction) { initMenu(); initTreeView(); - JButton visibilityButton = initVisibilityButton(); - setLayout(new MigLayout("inset 0", "[grow]", "[][grow][]")); - add(new JLabel("Log Sources:"), "grow, wrap"); + final JButton visibilityButton = initVisibilityButton(); + final JButton collapseButton = new JButton("\u2715"); + collapseButton.addActionListener(a -> toggleAction.run()); + + visibilityButton.putClientProperty(FlatClientProperties.STYLE_CLASS, "small"); + reloadButton.putClientProperty(FlatClientProperties.STYLE_CLASS, "small"); + collapseButton.putClientProperty(FlatClientProperties.STYLE_CLASS, "small"); + + final JToolBar toolBar = new JToolBar(); + toolBar.setFloatable(false); + toolBar.add(collapseButton); + toolBar.add(Box.createHorizontalGlue()); + toolBar.add(reloadButton); + toolBar.add(visibilityButton); + // tree takes the free height, the toolbar keeps its preferred height + setLayout(new MigLayout("inset 0", "[grow]", "[grow][]")); add(new JScrollPane(tree), "grow, wrap"); - add(new JLabel(""), "split 3, grow"); - add(reloadButton); - add(visibilityButton); + add(toolBar, "growx"); actionWhenFocusLost(() -> tree.clearSelection(), Arrays.asList(tree, visibilityButton)); } @@ -129,11 +136,14 @@ private void initTreeView() { treeModel = new DefaultTreeModel(rootNode); tree = new JTree(treeModel); tree.setRootVisible(false); - DefaultTreeCellRenderer cellRenderer = new DefaultTreeCellRenderer(); - cellRenderer.setIcon(null); - cellRenderer.setLeafIcon(null); - cellRenderer.setOpenIcon(null); - tree.setCellRenderer(cellRenderer); + // NB: FlatLaf trees have no default icons, so keep its own renderer + if (!(UIManager.getLookAndFeel() instanceof FlatLaf)) { + DefaultTreeCellRenderer cellRenderer = new DefaultTreeCellRenderer(); + cellRenderer.setIcon(null); + cellRenderer.setLeafIcon(null); + cellRenderer.setOpenIcon(null); + tree.setCellRenderer(cellRenderer); + } tree.setComponentPopupMenu(menu); tree.setEditable(false); tree.setShowsRootHandles(true); @@ -146,7 +156,8 @@ private void selectionChanged(TreeSelectionEvent treeSelectionEvent) { } private JButton initVisibilityButton() { - JButton button = new JButton("Visibility"); + JButton button = new JButton("Visibility \u25BE"); + button.setToolTipText("Set which log levels are shown for the selected sources"); button.addActionListener(a -> menu.show(button, 0, button.getHeight())); return button; } @@ -340,7 +351,7 @@ static Level of(int x) { public static void main(String... args) { JFrame frame = new JFrame(); - LogSourcesPanel logLevelPanel = new LogSourcesPanel(new JButton("dummy")); + LogSourcesPanel logLevelPanel = new LogSourcesPanel(new JButton("dummy"), null); LogSource root = LogSource.newRoot(); Set loggers = new HashSet<>(Arrays.asList( root.subSource("Hello:World"), diff --git a/src/main/java/org/scijava/ui/swing/console/LoggingPanel.java b/src/main/java/org/scijava/ui/swing/console/LoggingPanel.java index 2c064ed..7a401da 100644 --- a/src/main/java/org/scijava/ui/swing/console/LoggingPanel.java +++ b/src/main/java/org/scijava/ui/swing/console/LoggingPanel.java @@ -29,8 +29,7 @@ package org.scijava.ui.swing.console; -import java.awt.Color; -import java.awt.Dimension; +import java.awt.*; import java.awt.event.ActionEvent; import java.util.Collections; import java.util.Objects; @@ -58,6 +57,9 @@ import javax.swing.text.SimpleAttributeSet; import javax.swing.text.StyleConstants; +import com.formdev.flatlaf.FlatClientProperties; +import com.formdev.flatlaf.FlatLaf; + import net.miginfocom.swing.MigLayout; import org.scijava.Context; @@ -92,8 +94,7 @@ public class LoggingPanel extends JPanel implements LogListener private static final AttributeSet STYLE_TRACE = normal(Color.GRAY); private static final AttributeSet STYLE_OTHERS = normal(Color.GRAY); - private final TextFilterField textFilter = - new TextFilterField(" Text Search (Alt-F)"); + private final TextFilterField textFilter = new TextFilterField("Text Search (Alt-F)"); private final LogSourcesPanel sourcesPanel = initSourcesPanel(); private final ItemTextPane textArea; @@ -193,26 +194,47 @@ private static Color defaultInfoColor() { } private void initGui() { + // extra vertical padding, for a proper search bar look + final float fontSize = textFilter.getComponent().getFont().getSize2D() * 1.2f; + textFilter.getComponent().setMargin(new Insets((int) (fontSize/2), 0, (int) (fontSize/2), 0)); textFilter.setChangeListener(this::updateFilter); JPopupMenu menu = initMenu(); JButton menuButton = new JButton("\u22EE"); - menuButton.addActionListener(a -> - menu.show(menuButton, 0, menuButton.getHeight())); - - textFilterPanel.setLayout(new MigLayout("insets 0", "[][grow]", "[]")); - textFilterPanel.add(menuButton); + menuButton.setFont(menuButton.getFont().deriveFont(fontSize).deriveFont(Font.BOLD)); + menuButton.addActionListener(a -> menu.show(menuButton, 0, menuButton.getHeight())); + + if (UIManager.getLookAndFeel() instanceof FlatLaf) { + // embed the menu button at the left edge of the search field + menuButton.setFocusable(false); + menuButton.putClientProperty(FlatClientProperties.BUTTON_TYPE, + FlatClientProperties.BUTTON_TYPE_TOOLBAR_BUTTON); + textFilter.getComponent().putClientProperty( + FlatClientProperties.TEXT_FIELD_LEADING_COMPONENT, menuButton); + textFilterPanel.setLayout(new MigLayout("insets 0", "[grow]", "[]")); + } + else { + textFilterPanel.setLayout(new MigLayout("insets 0", "[][grow]", "[]")); + textFilterPanel.add(menuButton); + } textFilterPanel.add(textFilter.getComponent(), "grow"); sourcesPanel.setChangeListener(this::updateFilter); sourcesPanel.setMinimumSize(new Dimension()); textArea.setPopupMenu(menu); - textArea.getJComponent().setPreferredSize(new Dimension(200, 100)); + // 60 columns by 15 lines, consistent with ConsolePanel + final JComponent logComponent = textArea.getJComponent(); + final FontMetrics logMetrics = logComponent.getFontMetrics(logComponent.getFont()); + logComponent.setPreferredSize(new Dimension(logMetrics.charWidth('a') * 60, logMetrics.getHeight() * 15)); + // keeps the log readable when the divider is dragged to the right + textArea.getJComponent().setMinimumSize(new Dimension(150, 50)); splitPane.setBorder(BorderFactory.createEmptyBorder(1, 1, 1, 1)); - splitPane.setOneTouchExpandable(true); + // NB: no one-touch arrows, the sources panel must never take over the whole + // width. It is toggled from the menu, or by dragging the divider + splitPane.setOneTouchExpandable(false); setSourcesPanelVisible(false); splitPane.add(sourcesPanel); splitPane.add(textArea.getJComponent()); @@ -227,7 +249,7 @@ private void initGui() { private LogSourcesPanel initSourcesPanel() { JButton reloadButton = new JButton("Reload"); reloadButton.addActionListener(actionEvent -> reloadSources()); - return new LogSourcesPanel(reloadButton); + return new LogSourcesPanel(reloadButton, this::toggleSourcesPanel); } private void reloadSources() { @@ -247,16 +269,13 @@ public void actionPerformed(ActionEvent actionEvent) { private JPopupMenu initMenu() { JPopupMenu menu = new JPopupMenu(); - menu.add(newMenuItem("Copy", "control C", - this::copySelectionToClipboard)); - registerKeyStroke("control C", "copyToClipBoard", - this::copySelectionToClipboard); - menu.add(newMenuItem("Clear", "alt C", - this::clear)); - registerKeyStroke("alt C", "clearLoggingPanel", - this::clear); - menu.add(newMenuItem("Log Sources", - this::toggleSourcesPanel)); + menu.add(newMenuItem("Copy", "control C", this::copySelectionToClipboard)); + registerKeyStroke("control C", "copyToClipBoard", this::copySelectionToClipboard); + menu.add(newMenuItem("Clear", "alt C", this::clear)); + registerKeyStroke("alt C", "clearLoggingPanel", this::clear); + menu.addSeparator(); + menu.add(newMenuItem("Log Sources", this::toggleSourcesPanel)); + menu.addSeparator(); menu.add(initSettingsMenu()); return menu; } diff --git a/src/main/java/org/scijava/ui/swing/console/TextFilterField.java b/src/main/java/org/scijava/ui/swing/console/TextFilterField.java index b0001fc..87bc95a 100644 --- a/src/main/java/org/scijava/ui/swing/console/TextFilterField.java +++ b/src/main/java/org/scijava/ui/swing/console/TextFilterField.java @@ -29,21 +29,20 @@ package org.scijava.ui.swing.console; -import java.awt.BorderLayout; -import java.awt.Color; -import java.awt.Font; +import java.util.Arrays; +import java.util.Locale; import java.util.function.Predicate; -import javax.swing.JLabel; import javax.swing.JTextField; -import javax.swing.SwingConstants; -import javax.swing.border.EmptyBorder; import javax.swing.event.DocumentEvent; import javax.swing.event.DocumentListener; +import com.formdev.flatlaf.FlatClientProperties; + /** * {@link TextFilterField} provides a {@link JTextField} and derives a text - * filter from the content. + * filter from the content. The filter accepts text containing all the + * (space separated) words typed by the user, ignoring case. * * @author Matthias Arzt */ @@ -51,16 +50,15 @@ class TextFilterField { private final JTextField textField = new JTextField(); - private JLabel prompt = new JLabel(); - - private Predicate filter = null; + private volatile Predicate filter = null; private Runnable changeListener = null; // -- constructor -- TextFilterField(String textForPrompt) { - initPrompt(textForPrompt); + textField.putClientProperty(FlatClientProperties.PLACEHOLDER_TEXT, textForPrompt.trim()); + textField.putClientProperty(FlatClientProperties.TEXT_FIELD_SHOW_CLEAR_BUTTON, true); textField.getDocument().addDocumentListener(new DocumentListener() { @Override @@ -98,41 +96,24 @@ public Predicate getFilter() { // -- Helper methods -- private Predicate calculateFilter() { - String text = textField.getText(); - final String[] words = text.split(" "); + final String[] words = Arrays.stream(textField.getText().toLowerCase( + Locale.ROOT).split("\\s+")).filter(w -> !w.isEmpty()).toArray( + String[]::new); + if (words.length == 0) return s -> true; return s -> { - for (String word : words) - if (!s.contains(word)) return false; + final String lower = s.toLowerCase(Locale.ROOT); + for (final String word : words) + if (!lower.contains(word)) return false; return true; }; } private void onUserInputChanged() { filter = null; - updatePromptVisibility(); notifyChangeListener(); } private void notifyChangeListener() { if (changeListener != null) changeListener.run(); } - - private void initPrompt(String text) { - prompt.setText(text); - prompt.setFont(textField.getFont().deriveFont(Font.ITALIC)); - prompt.setForeground(changeAlpha(textField.getForeground(), 128)); - prompt.setBorder(new EmptyBorder(textField.getInsets())); - prompt.setHorizontalAlignment(SwingConstants.LEADING); - textField.setLayout(new BorderLayout()); - textField.add(prompt); - updatePromptVisibility(); - } - - private static Color changeAlpha(Color color, int alpha) { - return new Color(color.getRed(), color.getGreen(), color.getBlue(), alpha); - } - - private void updatePromptVisibility() { - prompt.setVisible(textField.getDocument().getLength() == 0); - } } diff --git a/src/main/java/org/scijava/ui/swing/task/SwingTaskMonitorComponent.java b/src/main/java/org/scijava/ui/swing/task/SwingTaskMonitorComponent.java index 14d1bfc..da1dbd8 100644 --- a/src/main/java/org/scijava/ui/swing/task/SwingTaskMonitorComponent.java +++ b/src/main/java/org/scijava/ui/swing/task/SwingTaskMonitorComponent.java @@ -261,7 +261,7 @@ public void mouseClicked(java.awt.event.MouseEvent evt) { Task selectedTask = taskTableModel.getTask(row); if (selectedTask!=null) { if (SwingTaskMonitorComponent.this.confirmBeforeCancel) { - int userconfirmation = JOptionPane.showConfirmDialog(null, "Do you really want to cancel this task ?", "Canceling " + selectedTask.getName(), JOptionPane.YES_NO_OPTION); + int userconfirmation = JOptionPane.showConfirmDialog(null, "Do you really want to cancel this task?", "Canceling " + selectedTask.getName(), JOptionPane.YES_NO_OPTION); if (userconfirmation == JOptionPane.YES_OPTION) { selectedTask.cancel("User cancellation (table task)"); } diff --git a/src/main/java/org/scijava/ui/swing/widget/SwingColorWidget.java b/src/main/java/org/scijava/ui/swing/widget/SwingColorWidget.java index dc22b11..15bd8be 100644 --- a/src/main/java/org/scijava/ui/swing/widget/SwingColorWidget.java +++ b/src/main/java/org/scijava/ui/swing/widget/SwingColorWidget.java @@ -86,7 +86,7 @@ public class SwingColorWidget extends SwingInputWidget implements @Override public void actionPerformed(final ActionEvent e) { - final Color choice = showColorDialog(choose, "Select a color", color); + final Color choice = showColorDialog(choose, "Select a Color", color); if (choice == null) return; color = choice; updateModel(); @@ -141,7 +141,7 @@ public boolean supports(final WidgetModel model) { public static Color showColorDialog(final Component component, final String title, final Color initialColor) throws HeadlessException { - final JColorChooser pane = createColorChooser(initialColor); + final JColorChooser pane = getSharedColorChooser(initialColor); class ColorTracker implements ActionListener { @@ -167,12 +167,34 @@ public Color getColor() { JColorChooser.createDialog(component, title, true, pane, ok, null); dialog.setVisible(true); + dialog.dispose(); return ok.getColor(); } // -- Helper methods -- + private static JColorChooser sharedChooser; + private static String sharedChooserLaf; + + /** + * Returns a chooser shared by all color widgets, so that state such as the + * recent colors of the swatches panel survives between dialogs. It is + * rebuilt if the look and feel changed, since a chooser that is not shown + * does not get updated and would keep the old UI. + */ + private static synchronized JColorChooser getSharedColorChooser( + final Color initialColor) + { + final String laf = UIManager.getLookAndFeel().getClass().getName(); + if (sharedChooser == null || !laf.equals(sharedChooserLaf)) { + sharedChooser = createColorChooser(initialColor); + sharedChooserLaf = laf; + } + sharedChooser.setColor(initialColor != null ? initialColor : Color.white); + return sharedChooser; + } + /** * Creates a new {@link JColorChooser} with panels in a desirable order. *

diff --git a/src/main/java/org/scijava/ui/swing/widget/SwingDateWidget.java b/src/main/java/org/scijava/ui/swing/widget/SwingDateWidget.java index e55f529..aa344b5 100644 --- a/src/main/java/org/scijava/ui/swing/widget/SwingDateWidget.java +++ b/src/main/java/org/scijava/ui/swing/widget/SwingDateWidget.java @@ -29,15 +29,18 @@ package org.scijava.ui.swing.widget; +import java.awt.Color; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.util.Date; import javax.swing.JPanel; +import javax.swing.UIManager; -import net.sourceforge.jdatepicker.impl.JDatePanelImpl; -import net.sourceforge.jdatepicker.impl.JDatePickerImpl; -import net.sourceforge.jdatepicker.impl.UtilDateModel; +import org.jdatepicker.ComponentColorDefaults; +import org.jdatepicker.ComponentColorDefaults.Key; +import org.jdatepicker.JDatePicker; +import org.jdatepicker.UtilDateModel; import org.scijava.log.LogService; import org.scijava.plugin.Parameter; @@ -75,8 +78,9 @@ public void set(final WidgetModel model) { super.set(model); dateModel = new UtilDateModel(); - final JDatePickerImpl datePicker = - new JDatePickerImpl(new JDatePanelImpl(dateModel), null); + applyThemeColors(); + // NB: the picker must be built on our model, or user picks never reach it + final JDatePicker datePicker = new JDatePicker(dateModel); setToolTip(datePicker); getComponent().add(datePicker); datePicker.addActionListener(this); @@ -84,6 +88,39 @@ public void set(final WidgetModel model) { refreshWidget(); } + // -- Helper methods -- + + /** + * Maps the picker colors to the current look and feel. Note that + * {@link ComponentColorDefaults} is a global singleton read when a picker + * is created, so existing pickers keep their colors on a theme change + */ + private static void applyThemeColors() { + setColor(Key.BG_MONTH_SELECTOR, "TableHeader.background"); + setColor(Key.FG_MONTH_SELECTOR, "TableHeader.foreground"); + setColor(Key.BG_GRID_HEADER, "TableHeader.background"); + setColor(Key.FG_GRID_HEADER, "TableHeader.foreground"); + setColor(Key.BG_GRID, "Table.background"); + setColor(Key.BG_GRID_NOT_SELECTABLE, "Panel.background"); + setColor(Key.FG_GRID_THIS_MONTH, "Table.foreground"); + setColor(Key.FG_GRID_OTHER_MONTH, "Label.disabledForeground"); + setColor(Key.FG_GRID_TODAY, "Component.accentColor"); + setColor(Key.FG_GRID_SELECTED, "Table.selectionForeground"); + setColor(Key.BG_GRID_SELECTED, "Table.selectionBackground"); + setColor(Key.FG_GRID_TODAY_SELECTED, "Table.selectionForeground"); + setColor(Key.BG_GRID_TODAY_SELECTED, "Table.selectionBackground"); + setColor(Key.BG_TODAY_SELECTOR, "Panel.background"); + setColor(Key.FG_TODAY_SELECTOR_ENABLED, "Label.foreground"); + setColor(Key.FG_TODAY_SELECTOR_DISABLED, "Label.disabledForeground"); + setColor(Key.POPUP_BORDER, "Component.borderColor"); + } + + /** Sets a picker color from a UIManager key, if the key is defined */ + private static void setColor(final Key key, final String uiKey) { + final Color color = UIManager.getColor(uiKey); + if (color != null) ComponentColorDefaults.getInstance().setColor(key, color); + } + // -- Typed methods -- @Override diff --git a/src/main/java/org/scijava/ui/swing/widget/SwingFileListWidget.java b/src/main/java/org/scijava/ui/swing/widget/SwingFileListWidget.java index 2a2b1ce..8aa9dde 100644 --- a/src/main/java/org/scijava/ui/swing/widget/SwingFileListWidget.java +++ b/src/main/java/org/scijava/ui/swing/widget/SwingFileListWidget.java @@ -29,7 +29,9 @@ package org.scijava.ui.swing.widget; +import java.awt.Cursor; import java.awt.Dimension; +import java.awt.datatransfer.Clipboard; import java.awt.dnd.DnDConstants; import java.awt.dnd.DropTarget; import java.awt.dnd.DropTargetAdapter; @@ -38,33 +40,43 @@ import java.awt.dnd.DropTargetEvent; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; +import java.awt.event.InputEvent; +import java.awt.event.KeyEvent; import java.awt.event.MouseEvent; import java.awt.event.MouseListener; import java.io.File; import java.io.FileFilter; import java.io.IOException; +import java.io.UncheckedIOException; import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.TooManyListenersException; +import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import javax.swing.AbstractAction; import javax.swing.Box; import javax.swing.DefaultListModel; import javax.swing.JButton; +import javax.swing.JComponent; import javax.swing.JList; import javax.swing.JPanel; import javax.swing.JScrollPane; +import javax.swing.KeyStroke; +import javax.swing.SwingWorker; import javax.swing.TransferHandler; import net.miginfocom.swing.MigLayout; import org.scijava.log.LogService; -import org.scijava.module.ModuleService; import org.scijava.plugin.Parameter; import org.scijava.plugin.Plugin; -import org.scijava.thread.ThreadService; import org.scijava.widget.FileListWidget; import org.scijava.widget.FileWidget; import org.scijava.widget.InputWidget; @@ -82,11 +94,7 @@ public class SwingFileListWidget extends SwingInputWidget implements @Parameter private LogService logService; - @Parameter - private ModuleService moduleService; - - @Parameter - private ThreadService threadService; + private boolean loadingFolder; private JList paths; private JButton addFilesButton; @@ -99,33 +107,33 @@ public File[] getValue() { DefaultListModel listModel = // (DefaultListModel) paths.getModel(); List fileList = Collections.list(listModel.elements()); - return fileList.toArray(new File[fileList.size()]); + return fileList.toArray(new File[0]); } @Override public void set(final WidgetModel model) { super.set(model); paths = new JList<>(new DefaultListModel<>()); - //paths.setMinimumSize(new Dimension(150, 50)); - //paths.setMaximumSize(new Dimension(150, 50)); paths.setDragEnabled(true); final String style = model.getItem().getWidgetStyle(); - paths.setTransferHandler(new FileListTransferHandler(style)); + paths.setTransferHandler(new FileListTransferHandler(style, paths + .getTransferHandler())); JScrollPane scrollPane = new JScrollPane(paths); scrollPane.setPreferredSize(new Dimension(350, 100)); paths.addMouseListener(this); // scrollPane.addMouseListener(l); setToolTip(scrollPane); - getComponent().add(scrollPane); + getComponent().add(scrollPane, "grow"); getComponent().add(Box.createHorizontalStrut(3)); - JPanel buttonPanel = new JPanel(new MigLayout()); + // no insets and a 3 px vertical gap, to match the other file widgets + JPanel buttonPanel = new JPanel(new MigLayout("ins 0, gapy 3")); // Set button label dependent on widget style - String filesLabel = model.isStyle(DIRECTORIES_ONLY) ? "Add folders..." - : "Add files..."; + String filesLabel = model.isStyle(DIRECTORIES_ONLY) ? "Add Folders..." + : "Add Files..."; addFilesButton = new JButton(filesLabel); setToolTip(addFilesButton); buttonPanel.add(addFilesButton, "wrap, grow"); @@ -133,8 +141,10 @@ public void set(final WidgetModel model) { // Only show folder button if style allows files if (!model.isStyle(DIRECTORIES_ONLY)) { - addFolderButton = new JButton("Add folder content..."); - setToolTip(addFolderButton); + addFolderButton = new JButton("Add Folder..."); + // set directly: setToolTip() would replace it with the description + addFolderButton.setToolTipText("Adds all matching files from the " + + "chosen folder
and its subfolders (recursive)"); // add TransferHandler to accept dropped folders addFolderButton.setTransferHandler(new FolderTransferHandler()); @@ -149,12 +159,33 @@ public void set(final WidgetModel model) { addFolderButton.addActionListener(this); } - removeFilesButton = new JButton("Remove selected"); - setToolTip(removeFilesButton); + removeFilesButton = new JButton("Remove"); + // set directly: setToolTip() would replace it with the description + removeFilesButton.setToolTipText("Removes the selected entries " + + "from the list
(Delete or double-clicking an entry also removes it)"); + removeFilesButton.setEnabled(false); + paths.addListSelectionListener(e -> { + if (!e.getValueIsAdjusting()) + removeFilesButton.setEnabled(!paths.isSelectionEmpty()); + }); + // Delete (or Backspace, e.g. the macOS delete key) removes the selection + final String removeKey = "removeSelected"; + for (final int key : new int[] { KeyEvent.VK_DELETE, + KeyEvent.VK_BACK_SPACE }) + { + paths.getInputMap().put(KeyStroke.getKeyStroke(key, 0), removeKey); + } + paths.getActionMap().put(removeKey, new AbstractAction() { + + @Override + public void actionPerformed(final ActionEvent e) { + removeSelected(); + } + }); buttonPanel.add(removeFilesButton, "wrap, grow"); removeFilesButton.addActionListener(this); - clearButton = new JButton("Clear list"); + clearButton = new JButton("Clear"); setToolTip(clearButton); buttonPanel.add(clearButton, "grow"); clearButton.addActionListener(this); @@ -196,27 +227,27 @@ public void actionPerformed(final ActionEvent e) { style = FileListWidget.FILES_ONLY; // default } - fileList = ui().chooseFiles(null, fileList, filter, style); + fileList = chooseFiles(null, fileList, filter, style); if (fileList == null) return; - fileList.forEach(file -> listModel.addElement(file)); + addUnique(fileList); + updateModel(); + return; } else if (e.getSource() == addFolderButton) { - File folder = ui().chooseFile(null, FileWidget.DIRECTORY_STYLE); + File folder = chooseFile(null, FileWidget.DIRECTORY_STYLE); if (folder == null) return; - List fileList = getFilesFromFolder(folder); - fileList.forEach(file -> listModel.addElement(file)); + addFolderContent(folder); + return; } else if (e.getSource() == removeFilesButton) { - // Remove selected files - List selected = paths.getSelectedValuesList(); - for (File f : selected) { - listModel.removeElement(f); - } + removeSelected(); + return; } else if (e.getSource() == clearButton) { // Clear the file selection listModel.removeAllElements(); } paths.setModel(listModel); + updateButtons(); updateModel(); } @@ -232,21 +263,14 @@ protected void doRefresh() { } } paths.setModel(listModel); + updateButtons(); } @Override public void mouseClicked(MouseEvent e) { // handle double click if (e.getClickCount() == 2) { - DefaultListModel listModel = // - (DefaultListModel) paths.getModel(); - // Remove selected files - List selected = paths.getSelectedValuesList(); - for (File f : selected) { - listModel.removeElement(f); - } - paths.setModel(listModel); - updateModel(); + removeSelected(); } } @@ -272,23 +296,89 @@ public void mouseExited(MouseEvent e) { // -- Helper methods -- - private List getFilesFromFolder(File inputFolder) { - // get files in folder and add to listModel - List fileList = new ArrayList<>(); - final WidgetModel widgetModel = get(); - final String widgetStyle = widgetModel.getItem().getWidgetStyle(); - FileFilter filter = SwingFileWidget.createFileFilter(widgetStyle); - try { - fileList = Arrays - .asList((Files.walk(inputFolder.toPath()) - .filter(path -> filter.accept(path.toFile()))) - .map(path -> path.toFile()) - .toArray(File[]::new)); - } catch (IOException exc) { - logService - .error("Error when trying to retrieve file list", exc); + /** Removes the selected entries from the list */ + private void removeSelected() { + final DefaultListModel listModel = // + (DefaultListModel) paths.getModel(); + for (final File f : paths.getSelectedValuesList()) { + listModel.removeElement(f); + } + paths.setModel(listModel); + updateButtons(); + updateModel(); + } + + /** Adds the given files to the list, skipping those already listed */ + private void addUnique(final List files) { + final DefaultListModel current = // + (DefaultListModel) paths.getModel(); + // Replace the model at once, instead of firing an event per file + final DefaultListModel merged = new DefaultListModel<>(); + final Set seen = new HashSet<>(); + Collections.list(current.elements()).forEach(f -> { + merged.addElement(f); + seen.add(f); + }); + files.stream().filter(seen::add).forEach(merged::addElement); + paths.setModel(merged); + updateButtons(); + } + + /** Enables the buttons that only make sense for a non-empty list */ + private void updateButtons() { + clearButton.setEnabled(paths.getModel().getSize() > 0); + } + + /** + * Adds the files of a folder (and its subfolders) that match the widget + * style. The folder is scanned in the background, so the UI stays responsive + */ + private void addFolderContent(final File folder) { + if (loadingFolder) return; + loadingFolder = true; + final FileFilter filter = SwingFileWidget.createFileFilter(get().getItem() + .getWidgetStyle()); + getComponent().setCursor(Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR)); + if (addFolderButton != null) addFolderButton.setEnabled(false); + new SwingWorker, Void>() { + + @Override + protected List doInBackground() { + return getFilesFromFolder(folder, filter); + } + + @Override + protected void done() { + try { + addUnique(get()); + updateModel(); + } + catch (final InterruptedException | ExecutionException exc) { + logService.error("Error when trying to retrieve file list", exc); + } + finally { + loadingFolder = false; + getComponent().setCursor(Cursor.getDefaultCursor()); + if (addFolderButton != null) addFolderButton.setEnabled(true); + } + } + }.execute(); + } + + /** Walks the folder tree. Must not be called on the EDT */ + private List getFilesFromFolder(final File inputFolder, + final FileFilter filter) + { + try (final Stream walk = Files.walk( + inputFolder.toPath())) + { + return walk.map(Path::toFile).filter(filter::accept) + .collect(Collectors.toList()); + } + catch (IOException | UncheckedIOException exc) { + logService.error("Error when trying to retrieve file list", exc); + return new ArrayList<>(); } - return fileList; } // -- Helper classes -- @@ -296,9 +386,33 @@ private List getFilesFromFolder(File inputFolder) { private class FileListTransferHandler extends TransferHandler { private final String style; + private final TransferHandler fallback; - public FileListTransferHandler(final String style) { + public FileListTransferHandler(final String style, + final TransferHandler fallback) + { this.style = style; + this.fallback = fallback; + } + + // Keep copy and drag out of the list working (default handler) + @Override + public int getSourceActions(final JComponent c) { + return fallback == null ? NONE : fallback.getSourceActions(c); + } + + @Override + public void exportToClipboard(final JComponent comp, + final Clipboard clip, final int action) throws IllegalStateException + { + if (fallback != null) fallback.exportToClipboard(comp, clip, action); + } + + @Override + public void exportAsDrag(final JComponent comp, final InputEvent e, + final int action) + { + if (fallback != null) fallback.exportAsDrag(comp, e, action); } @Override @@ -306,7 +420,6 @@ public boolean canImport(final TransferHandler.TransferSupport support) { return SwingFileWidget.hasFiles(support); } - @SuppressWarnings("unchecked") @Override public boolean importData(final TransferHandler.TransferSupport support) { final List allFiles = SwingFileWidget.getFiles(support); @@ -317,11 +430,7 @@ public boolean importData(final TransferHandler.TransferSupport support) { logService.warn("Some files were excluded " + "for not matching the input requirements (" + style + ")"); } - final JList jlist = (JList) support.getComponent(); - final DefaultListModel model = (DefaultListModel) // - jlist.getModel(); - files.forEach(f -> model.addElement(f)); - jlist.setModel(model); + addUnique(files); updateModel(); return true; } @@ -345,20 +454,13 @@ public boolean importData(final TransferHandler.TransferSupport support) { if (!folder.isDirectory()) return false; - // get all files matching filter from folder and subfolders - List fileList = getFilesFromFolder(folder); - - // add files to model - DefaultListModel model = // - (DefaultListModel) paths.getModel(); - fileList.forEach(file -> model.addElement(file)); - paths.setModel(model); - updateModel(); + // add all files matching filter from folder and subfolders + addFolderContent(folder); return true; } } - private class ButtonDropTargetListener extends DropTargetAdapter { + private static class ButtonDropTargetListener extends DropTargetAdapter { @Override public void drop(DropTargetDropEvent dtde) { diff --git a/src/main/java/org/scijava/ui/swing/widget/SwingFileWidget.java b/src/main/java/org/scijava/ui/swing/widget/SwingFileWidget.java index 8080118..04ba064 100644 --- a/src/main/java/org/scijava/ui/swing/widget/SwingFileWidget.java +++ b/src/main/java/org/scijava/ui/swing/widget/SwingFileWidget.java @@ -29,11 +29,12 @@ package org.scijava.ui.swing.widget; +import java.awt.datatransfer.Clipboard; import java.awt.datatransfer.DataFlavor; import java.awt.datatransfer.UnsupportedFlavorException; -import java.awt.dnd.InvalidDnDOperationException; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; +import java.awt.event.InputEvent; import java.io.File; import java.io.FileFilter; import java.io.IOException; @@ -45,15 +46,16 @@ import javax.swing.Box; import javax.swing.JButton; +import javax.swing.JComponent; import javax.swing.JPanel; import javax.swing.JTextField; +import javax.swing.Timer; import javax.swing.TransferHandler; import javax.swing.event.DocumentEvent; import javax.swing.event.DocumentListener; -import org.scijava.plugin.Parameter; +import com.formdev.flatlaf.FlatClientProperties; import org.scijava.plugin.Plugin; -import org.scijava.ui.UIService; import org.scijava.widget.FileListWidget; import org.scijava.widget.FileWidget; import org.scijava.widget.InputWidget; @@ -69,9 +71,6 @@ public class SwingFileWidget extends SwingInputWidget implements FileWidget, ActionListener, DocumentListener { - @Parameter - private UIService uiService; - private JTextField path; private JButton browse; @@ -90,16 +89,21 @@ public void set(final WidgetModel model) { super.set(model); path = new JTextField(16); + path.putClientProperty(FlatClientProperties.TEXT_FIELD_SHOW_CLEAR_BUTTON, true); path.setDragEnabled(true); + path.putClientProperty(FlatClientProperties.PLACEHOLDER_TEXT, model + .isStyle(FileWidget.DIRECTORY_STYLE) + ? "Browse, drop, or paste a folder path" + : "Browse, drop, or paste a file path"); final String style = model.getItem().getWidgetStyle(); - path.setTransferHandler(new FileTransferHandler(style)); + path.setTransferHandler(new FileTransferHandler(style, path.getTransferHandler())); setToolTip(path); getComponent().add(path); path.getDocument().addDocumentListener(this); getComponent().add(Box.createHorizontalStrut(3)); - browse = new JButton("Browse"); + browse = new JButton("Browse..."); setToolTip(browse); getComponent().add(browse); browse.addActionListener(this); @@ -135,7 +139,7 @@ else if (model.isStyle(FileWidget.SAVE_STYLE)) { else { style = FileWidget.OPEN_STYLE; } - file = uiService.chooseFile(file, style); + file = chooseFile(file, style); if (file == null) return; path.setText(file.getAbsolutePath()); @@ -337,63 +341,91 @@ public static List filterFiles(final List list, // -- Helper classes -- + /** Briefly outlines the path field as invalid (FlatLaf only) */ + private void flashError() { + path.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR); + final Timer timer = new Timer(1000, e -> path.putClientProperty(FlatClientProperties.OUTLINE, null)); + timer.setRepeats(false); + timer.start(); + } + + /** + * Handles file drops, delegating everything else (text paste, copy, cut, + * text drag) to the default handler of the text field + */ private class FileTransferHandler extends TransferHandler { private final String style; + private final TransferHandler fallback; - public FileTransferHandler(final String style) { + public FileTransferHandler(final String style, final TransferHandler fallback) { this.style = style; + this.fallback = fallback; + } + + @Override + public int getSourceActions(final JComponent c) { + return fallback == null ? NONE : fallback.getSourceActions(c); + } + + @Override + public void exportToClipboard(final JComponent comp, + final Clipboard clip, final int action) throws IllegalStateException + { + if (fallback != null) fallback.exportToClipboard(comp, clip, action); + } + + @Override + public void exportAsDrag(final JComponent comp, final InputEvent e, + final int action) + { + if (fallback != null) fallback.exportAsDrag(comp, e, action); } @Override public boolean canImport(final TransferHandler.TransferSupport support) { - if (!hasFiles(support)) return false; - - // We wish to test the content of the transfer data and - // determine if they are (a) files and (b) files we are - // actually interested in processing. So we need to call - // getTransferData() so that we can inspect the file names. - // Unfortunately, this will not always work. - // Under Windows, the Transferable instance - // will have transfer data ONLY while the mouse button is - // depressed. However, when the user releases the mouse - // button, this method will be called one last time. And when - // when this method attempts to getTransferData, Java will throw - // an InvalidDnDOperationException. Since we know that the - // exception is coming, we simply catch it and ignore it. - // See: - // https://coderanch.com/t/664525/java/Invalid-Drag-Drop-Exception + if (!hasFiles(support)) { + return fallback != null && fallback.canImport(support); + } + + // Transfer data may not be readable while dragging (Windows, and + // macOS). Accept and validate on drop try { final List allFiles = getFiles(support); - if (allFiles == null || allFiles.size() != 1) - return false; - - final FileFilter filter = SwingFileWidget - .createFileFilter(style); - final List files = SwingFileWidget.filterFiles(allFiles, - filter); - return files.size() == 1; - } catch (InvalidDnDOperationException exc) { + if (allFiles == null || allFiles.isEmpty()) return true; + if (allFiles.size() != 1) return false; + final FileFilter filter = SwingFileWidget.createFileFilter(style); + return SwingFileWidget.filterFiles(allFiles, filter).size() == 1; + } + catch (final RuntimeException exc) { return true; } } @Override - public boolean importData(TransferHandler.TransferSupport support) { - final List files = getFiles(support); - if (files == null) { - String text = getText(support); - if (text.equals("")) + public boolean importData(final TransferHandler.TransferSupport support) { + if (hasFiles(support)) { + final List files; + try { + files = getFiles(support); + } + catch (final RuntimeException exc) { + flashError(); return false; - // TODO check if text matches filter/style - ((JTextField) support.getComponent()).setText(text); + } + if (files == null || files.size() != 1) { + flashError(); + return false; + } + final FileFilter filter = SwingFileWidget.createFileFilter(style); + if (SwingFileWidget.filterFiles(files, filter).isEmpty()) { + flashError(); + return false; + } + path.setText(files.get(0).getAbsolutePath()); return true; } - if (files.size() != 1) return false; - - final File file = files.get(0); - ((JTextField) support.getComponent()).setText(file.getAbsolutePath()); - return true; + return fallback != null && fallback.importData(support); } } } diff --git a/src/main/java/org/scijava/ui/swing/widget/SwingInputWidget.java b/src/main/java/org/scijava/ui/swing/widget/SwingInputWidget.java index f6177a6..055eb88 100644 --- a/src/main/java/org/scijava/ui/swing/widget/SwingInputWidget.java +++ b/src/main/java/org/scijava/ui/swing/widget/SwingInputWidget.java @@ -30,6 +30,10 @@ package org.scijava.ui.swing.widget; import java.awt.Color; +import java.awt.Window; +import java.io.File; +import java.io.FileFilter; +import java.util.Arrays; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -43,6 +47,7 @@ import org.scijava.ui.AbstractUIInputWidget; import org.scijava.ui.UserInterface; +import org.scijava.ui.swing.AbstractSwingUI; import org.scijava.ui.swing.SwingUI; import org.scijava.widget.WidgetModel; @@ -64,8 +69,11 @@ public abstract class SwingInputWidget extends public void set(final WidgetModel model) { super.set(model); uiComponent = new JPanel(); + // Zero column gaps: empty trailing columns must not take up space, so that + // single-component widgets align with the right edge. Widgets needing + // spacing between components add it explicitly final MigLayout layout = - new MigLayout("fillx,ins 3 0 3 0", "[fill,grow|pref]"); + new MigLayout("fillx,ins 3 0 3 0", "[fill,grow]0[pref]0[pref]"); uiComponent.setLayout(layout); } @@ -98,6 +106,41 @@ protected UserInterface ui() { // -- Helper methods -- + /** + * Asks the user for a file, with the dialog shown relative to this widget + * rather than the application frame. + * + * @see org.scijava.ui.UserInterface#chooseFile(File, String) + */ + protected File chooseFile(final File file, final String style) { + final Window parent = SwingUtilities.getWindowAncestor(getComponent()); + final UserInterface ui = ui(); + if (ui instanceof AbstractSwingUI) { + return ((AbstractSwingUI) ui).chooseFile(parent, file, style); + } + return ui.chooseFile(file, style); + } + + /** + * Asks the user for files, with the dialog shown relative to this widget + * rather than the application frame. + * + * @see org.scijava.ui.UserInterface#chooseFiles(File, List, FileFilter, + * String) + */ + protected List chooseFiles(final File start, final List files, + final FileFilter filter, final String style) + { + final Window parent = SwingUtilities.getWindowAncestor(getComponent()); + final UserInterface ui = ui(); + if (ui instanceof AbstractSwingUI) { + final File[] result = ((AbstractSwingUI) ui).chooseFiles(parent, start, + files.toArray(new File[0]), filter, style); + return result == null ? null : Arrays.asList(result); + } + return ui.chooseFiles(start, files, filter, style); + } + /** Assigns the model's description as the given component's tool tip. */ protected void setToolTip(final JComponent c) { tooltipped.add(c); diff --git a/src/main/java/org/scijava/ui/swing/widget/SwingNumberWidget.java b/src/main/java/org/scijava/ui/swing/widget/SwingNumberWidget.java index e2f0e80..db74ca8 100644 --- a/src/main/java/org/scijava/ui/swing/widget/SwingNumberWidget.java +++ b/src/main/java/org/scijava/ui/swing/widget/SwingNumberWidget.java @@ -133,7 +133,9 @@ else if (model.isStyle(NumberWidget.SLIDER_STYLE)) { spinner.setPreferredSize(spinnerSize); fixSpinner(type); setToolTip(spinner); - getComponent().add(spinner); + // add spacing only if there is a slider or scroll bar before the spinner + getComponent().add(spinner, slider != null || scrollBar != null + ? "gapleft 6" : ""); limitWidth(200); spinner.addChangeListener(this); diff --git a/src/main/java/org/scijava/ui/swing/widget/SwingTextWidget.java b/src/main/java/org/scijava/ui/swing/widget/SwingTextWidget.java index 3be3765..1ac5f27 100644 --- a/src/main/java/org/scijava/ui/swing/widget/SwingTextWidget.java +++ b/src/main/java/org/scijava/ui/swing/widget/SwingTextWidget.java @@ -41,6 +41,7 @@ import javax.swing.text.DocumentFilter; import javax.swing.text.JTextComponent; +import com.formdev.flatlaf.FlatClientProperties; import org.scijava.log.LogService; import org.scijava.plugin.Plugin; import org.scijava.widget.InputWidget; @@ -102,10 +103,12 @@ public void set(final WidgetModel model) { } else if (model.isStyle(TextWidget.PASSWORD_STYLE)) { textComponent = new JPasswordField("", columns); + textComponent.putClientProperty(FlatClientProperties.STYLE, "showRevealButton: true; showCapsLock: true"); } else { textComponent = new JTextField("", columns); } + textComponent.putClientProperty(FlatClientProperties.TEXT_FIELD_SHOW_CLEAR_BUTTON, true); setToolTip(textComponent); getComponent().add(addScrollPane ? new JScrollPane(textComponent) : textComponent); limitLength(); diff --git a/src/test/java/org/scijava/ui/swing/widget/SwingWidgetLayoutDemo.java b/src/test/java/org/scijava/ui/swing/widget/SwingWidgetLayoutDemo.java new file mode 100644 index 0000000..97470e6 --- /dev/null +++ b/src/test/java/org/scijava/ui/swing/widget/SwingWidgetLayoutDemo.java @@ -0,0 +1,199 @@ +/* + * #%L + * SciJava UI components for Java Swing. + * %% + * Copyright (C) 2010 - 2026 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +package org.scijava.ui.swing.widget; + +import java.io.File; +import java.util.Date; + +import org.scijava.Context; +import org.scijava.ItemVisibility; +import org.scijava.command.Command; +import org.scijava.command.CommandService; +import org.scijava.object.ObjectService; +import org.scijava.plugin.Parameter; +import org.scijava.ui.UIService; +import org.scijava.util.ColorRGB; +import org.scijava.widget.Button; +import org.scijava.widget.ChoiceWidget; +import org.scijava.widget.FileListWidget; +import org.scijava.widget.FileWidget; +import org.scijava.widget.NumberWidget; +import org.scijava.widget.TextWidget; + +/** + * Shows one instance of every widget supported by the Swing UI in a single + * dialog, for visual testing of layout and alignment (e.g., that all widgets + * share the same right edge). + */ +public class SwingWidgetLayoutDemo implements Command { + + /** Dummy type to populate the object widget. */ + public static class Thing { + + private final String name; + + public Thing(final String name) { + this.name = name; + } + + @Override + public String toString() { + return name; + } + } + + // -- Message -- + + @Parameter(visibility = ItemVisibility.MESSAGE) + private String message = "Every supported widget, for layout testing"; + + // -- Text -- + + @Parameter(label = "Text (default)", persist = false) + private String text = "text"; + + @Parameter(label = "Text (field)", style = TextWidget.FIELD_STYLE, + persist = false) + private String field = "field"; + + @Parameter(label = "Text (password)", style = TextWidget.PASSWORD_STYLE, + persist = false) + private String password = "secret"; + + @Parameter(label = "Text (area)", style = TextWidget.AREA_STYLE, + persist = false) + private String area = "area\nmultiple lines"; + + @Parameter(label = "Character", persist = false) + private Character character = 'c'; + + // -- Number -- + + @Parameter(label = "Number (spinner)", persist = false) + private Double spinner = 1.5; + + @Parameter(label = "Number (integer)", min = "0", max = "100", + persist = false) + private Integer integer = 10; + + @Parameter(label = "Number (slider)", style = NumberWidget.SLIDER_STYLE, + min = "0", max = "10", stepSize = "0.01", persist = false) + private Double slider = 1d; + + @Parameter(label = "Number (scroll bar)", + style = NumberWidget.SCROLL_BAR_STYLE, min = "0", max = "10", + stepSize = "0.01", persist = false) + private Double scrollBar = 1d; + + // -- Boolean -- + + @Parameter(label = "Toggle", persist = false) + private Boolean toggle = true; + + // -- Choices -- + + @Parameter(label = "Choice (combo box)", choices = { "One", "Two", "Three" }, + persist = false) + private String choice = "One"; + + @Parameter(label = "Choice (radio, horizontal)", + style = ChoiceWidget.RADIO_BUTTON_HORIZONTAL_STYLE, + choices = { "One", "Two", "Three" }, persist = false) + private String radioH = "One"; + + @Parameter(label = "Choice (radio, vertical)", + style = ChoiceWidget.RADIO_BUTTON_VERTICAL_STYLE, + choices = { "One", "Two", "Three" }, persist = false) + private String radioV = "One"; + + @Parameter(label = "Object", persist = false) + private Thing thing; + + // -- Files -- + + @Parameter(label = "File (open)", style = FileWidget.OPEN_STYLE, + persist = false) + private File open; + + @Parameter(label = "File (open, tif/png)", + style = FileWidget.OPEN_STYLE + ",extensions:tif/png", persist = false) + private File openFiltered; + + @Parameter(label = "File (save)", style = FileWidget.SAVE_STYLE, + persist = false) + private File save; + + @Parameter(label = "Folder", style = FileWidget.DIRECTORY_STYLE, + persist = false) + private File directory; + + @Parameter(label = "File list (files)", style = FileListWidget.FILES_ONLY, + persist = false) + private File[] files; + + @Parameter(label = "File list (folders)", + style = FileListWidget.DIRECTORIES_ONLY, persist = false) + private File[] folders; + + @Parameter(label = "File list (both)", + style = FileListWidget.FILES_AND_DIRECTORIES, persist = false) + private File[] both; + + // -- Other -- + + @Parameter(label = "Date", persist = false) + private Date date = new Date(); + + @Parameter(label = "Color", persist = false) + private ColorRGB color = new ColorRGB(255, 128, 0); + + @Parameter(label = "Button", callback = "onButton", persist = false) + private Button button; + + @Override + public void run() { + // Nothing to do + } + + @SuppressWarnings("unused") + private void onButton() { + System.out.println("Button pressed"); + } + + public static void main(final String... args) throws Exception { + final Context context = new Context(); + final ObjectService objectService = context.service(ObjectService.class); + objectService.addObject(new Thing("Thing A")); + objectService.addObject(new Thing("Thing B")); + context.service(UIService.class).showUI(); + context.service(CommandService.class).run(SwingWidgetLayoutDemo.class, + true); + } +}