diff --git a/src/main/java/org/mastodon/mamut/MainWindow.java b/src/main/java/org/mastodon/mamut/MainWindow.java index 7037106e1..1f5d48572 100644 --- a/src/main/java/org/mastodon/mamut/MainWindow.java +++ b/src/main/java/org/mastodon/mamut/MainWindow.java @@ -62,6 +62,7 @@ import javax.swing.JPanel; import javax.swing.JSeparator; import javax.swing.SwingConstants; +import javax.swing.SwingUtilities; import javax.swing.WindowConstants; import org.mastodon.app.MastodonIcons; @@ -74,8 +75,11 @@ import org.mastodon.mamut.views.table.MamutViewTableFactory; import org.mastodon.mamut.views.trackscheme.MamutBranchViewTrackSchemeFactory; import org.mastodon.mamut.views.trackscheme.MamutViewTrackSchemeFactory; +import org.mastodon.ui.commandfinder.CommandFinder; import org.mastodon.ui.keymap.KeyConfigContexts; import org.mastodon.util.RunnableActionPair; +import org.scijava.ui.behaviour.util.Actions; +import org.scijava.ui.behaviour.util.InputActionBindings; import bdv.ui.keymap.Keymap; import net.miginfocom.swing.MigLayout; @@ -98,7 +102,8 @@ public MainWindow( final ProjectModel appModel ) setLocationByPlatform( true ); setLocationRelativeTo( null ); - // Re-register save actions, this time using this frame as parent component. + // Re-register save actions, this time using this frame as parent + // component. ProjectActions.installAppActions( appModel.getProjectActions(), appModel, this ); // Views: @@ -114,7 +119,7 @@ public MainWindow( final ProjectModel appModel ) prepareButton( tableButton, "table", TABLE_ICON_MEDIUM ); buttonsPanel.add( tableButton, "grow" ); - final JButton bdvButton = new JButton( new RunnableActionPair( MamutViewBdvFactory.NEW_BDV_VIEW, + final JButton bdvButton = new JButton( new RunnableActionPair( MamutViewBdvFactory.NEW_BDV_VIEW, () -> projectActionMap.get( MamutViewBdvFactory.NEW_BDV_VIEW ).actionPerformed( null ), () -> projectActionMap.get( MamutBranchViewBdvFactory.NEW_BRANCH_BDV_VIEW ).actionPerformed( null ) ) ); prepareButton( bdvButton, "bdv", BDV_ICON_MEDIUM ); @@ -124,9 +129,9 @@ public MainWindow( final ProjectModel appModel ) prepareButton( selectionTableButton, "selection table", TABLE_ICON_MEDIUM ); buttonsPanel.add( selectionTableButton, "grow" ); - final JButton trackschemeButton = new JButton( new RunnableActionPair( MamutViewTrackSchemeFactory.NEW_TRACKSCHEME_VIEW, - () -> projectActionMap.get( MamutViewTrackSchemeFactory.NEW_TRACKSCHEME_VIEW ).actionPerformed( null ), - () -> projectActionMap.get( MamutBranchViewTrackSchemeFactory.NEW_BRANCH_TRACKSCHEME_VIEW ).actionPerformed( null ) ) ); + final JButton trackschemeButton = new JButton( new RunnableActionPair( MamutViewTrackSchemeFactory.NEW_TRACKSCHEME_VIEW, + () -> projectActionMap.get( MamutViewTrackSchemeFactory.NEW_TRACKSCHEME_VIEW ).actionPerformed( null ), + () -> projectActionMap.get( MamutBranchViewTrackSchemeFactory.NEW_BRANCH_TRACKSCHEME_VIEW ).actionPerformed( null ) ) ); prepareButton( trackschemeButton, "trackscheme", TRACKSCHEME_ICON_MEDIUM ); buttonsPanel.add( trackschemeButton, "grow, wrap" ); @@ -210,6 +215,25 @@ public void windowClosing( final WindowEvent e ) // Register to when the project model is closed. appModel.projectClosedListeners().add( () -> dispose() ); + + // Command finder. + final InputActionBindings keybindings = new InputActionBindings(); + SwingUtilities.replaceUIActionMap( content, keybindings.getConcatenatedActionMap() ); + SwingUtilities.replaceUIInputMap( content, JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT, + keybindings.getConcatenatedInputMap() ); + final Actions mwActions = new Actions( keymap.getConfig(), KeyConfigContexts.MASTODON ); + mwActions.install( keybindings, "main" ); + CommandFinder.build() + .context( appModel.getContext() ) + .inputTriggerConfig( appModel.getKeymap().getConfig() ) + .descriptionProvider( appModel.getWindowManager().getViewFactories().getCommandDescriptions() ) + .keyConfigContext( KeyConfigContexts.MASTODON ) + .register( appModel.getModelActions() ) + .register( appModel.getProjectActions() ) + .register( appModel.getPlugins().getPluginActions() ) + .modificationListeners( appModel.getKeymap().updateListeners() ) + .parent( this ) + .installOn( mwActions ); } /** @@ -330,7 +354,7 @@ public static void addMenus( final ViewMenu menu, final ActionMap actionMap ) // item( ProjectActions.IMPORT_SIMI ), // item( ProjectActions.IMPORT_MAMUT ), // item( ProjectActions.EXPORT_MAMUT ), - // separator(), + // separator(), item( WindowManager.PREFERENCES_DIALOG ), separator(), item( WindowManager.OPEN_ONLINE_DOCUMENTATION ) ) ); diff --git a/src/main/java/org/mastodon/mamut/MamutViews.java b/src/main/java/org/mastodon/mamut/MamutViews.java index 62f526a90..d44f21a3d 100644 --- a/src/main/java/org/mastodon/mamut/MamutViews.java +++ b/src/main/java/org/mastodon/mamut/MamutViews.java @@ -102,7 +102,7 @@ public < T extends MamutViewI > MamutViewFactory< T > getFactory( final Class< T return ( MamutViewFactory< T > ) factories.get( klass ); } - CommandDescriptionProvider getCommandDescriptions() + public CommandDescriptionProvider getCommandDescriptions() { return new CommandDescriptionProvider( KeyConfigScopes.MAMUT, KeyConfigContexts.MASTODON ) { diff --git a/src/main/java/org/mastodon/mamut/views/bdv/MamutViewBdv.java b/src/main/java/org/mastodon/mamut/views/bdv/MamutViewBdv.java index 9f1cd2756..09ffe67ba 100644 --- a/src/main/java/org/mastodon/mamut/views/bdv/MamutViewBdv.java +++ b/src/main/java/org/mastodon/mamut/views/bdv/MamutViewBdv.java @@ -73,6 +73,7 @@ import org.mastodon.ui.coloring.GraphColorGeneratorAdapter; import org.mastodon.ui.coloring.HasColorBarOverlay; import org.mastodon.ui.coloring.HasColoringModel; +import org.mastodon.ui.commandfinder.CommandFinder; import org.mastodon.ui.keymap.KeyConfigContexts; import org.mastodon.views.bdv.BdvContextProvider; import org.mastodon.views.bdv.BigDataViewerActionsMamut; @@ -278,6 +279,21 @@ public MamutViewBdv( final ProjectModel appModel ) // Notifies context provider that context changes when visibility mode changes. tracksOverlay.getVisibilities().getVisibilityListeners().add( contextProvider::notifyContextChanged ); + // Command finder. + final CommandFinder cf = CommandFinder.build() + .context( appModel.getContext() ) + .inputTriggerConfig( appModel.getKeymap().getConfig() ) + .keyConfigContexts( keyConfigContexts ) + .descriptionProvider( appModel.getWindowManager().getViewFactories().getCommandDescriptions() ) + .register( viewActions ) + .register( appModel.getModelActions() ) + .register( appModel.getProjectActions() ) + .register( appModel.getPlugins().getPluginActions() ) + .modificationListeners( appModel.getKeymap().updateListeners() ) + .parent( frame ) + .installOn( viewActions ); + cf.getDialog().setTitle( cf.getDialog().getTitle() + " - " + frame.getTitle() ); + MainWindow.addMenus( menu, actionMap ); appModel.getWindowManager().addWindowMenu( menu, actionMap ); MamutMenuBuilder.build( menu, actionMap, diff --git a/src/main/java/org/mastodon/mamut/views/grapher/MamutViewGrapher.java b/src/main/java/org/mastodon/mamut/views/grapher/MamutViewGrapher.java index a24639563..e48c091c5 100644 --- a/src/main/java/org/mastodon/mamut/views/grapher/MamutViewGrapher.java +++ b/src/main/java/org/mastodon/mamut/views/grapher/MamutViewGrapher.java @@ -1,6 +1,7 @@ package org.mastodon.mamut.views.grapher; -import net.imglib2.loops.LoopBuilder; +import java.util.function.BiConsumer; + import org.apache.commons.lang3.function.TriFunction; import org.mastodon.app.ui.ViewMenuBuilder; import org.mastodon.mamut.ProjectModel; @@ -14,6 +15,7 @@ import org.mastodon.ui.coloring.GraphColorGeneratorAdapter; import org.mastodon.ui.coloring.HasColorBarOverlay; import org.mastodon.ui.coloring.HasColoringModel; +import org.mastodon.ui.commandfinder.CommandFinder; import org.mastodon.ui.keymap.KeyConfigContexts; import org.mastodon.views.context.ContextChooser; import org.mastodon.views.context.HasContextChooser; @@ -25,7 +27,7 @@ import org.mastodon.views.grapher.display.FeatureGraphConfig; import org.mastodon.views.grapher.display.FeatureSpecPair; -import java.util.function.BiConsumer; +import net.imglib2.loops.LoopBuilder; public class MamutViewGrapher extends MamutView< DataGraph< Spot, Link >, DataVertex, DataEdge > implements HasContextChooser< Spot >, HasColoringModel, HasColorBarOverlay @@ -52,17 +54,31 @@ public class MamutViewGrapher extends MamutView< DataGraph< Spot, Link >, DataVe grapherInitializer.installActions( viewActions, viewBehaviours ); grapherInitializer.addSearchPanel( viewActions ); - TriFunction< ViewMenuBuilder.JMenuHandle, GraphColorGeneratorAdapter< Spot, Link, DataVertex, DataEdge >, + final TriFunction< ViewMenuBuilder.JMenuHandle, GraphColorGeneratorAdapter< Spot, Link, DataVertex, DataEdge >, DataDisplayPanel< Spot, Link >, ColoringModel > colorModelRegistration = ( menuHandle, coloringAdaptor, panel ) -> registerColoring( coloringAdaptor, menuHandle, panel::entitiesAttributesChanged ); - LoopBuilder.TriConsumer< ColorBarOverlay, ViewMenuBuilder.JMenuHandle, DataDisplayPanel< Spot, Link > > colorBarRegistration = + final LoopBuilder.TriConsumer< ColorBarOverlay, ViewMenuBuilder.JMenuHandle, DataDisplayPanel< Spot, Link > > colorBarRegistration = ( overlay, menuHandle, panel ) -> registerColorbarOverlay( overlay, menuHandle, panel::repaint ); - BiConsumer< ViewMenuBuilder.JMenuHandle, DataDisplayPanel< Spot, Link > > tagSetMenuRegistration = + final BiConsumer< ViewMenuBuilder.JMenuHandle, DataDisplayPanel< Spot, Link > > tagSetMenuRegistration = ( menuHandle, panel ) -> registerTagSetMenu( menuHandle, panel::entitiesAttributesChanged ); grapherInitializer.addMenusAndRegisterColors( colorModelRegistration, colorBarRegistration, tagSetMenuRegistration, keyConfigContexts ); grapherInitializer.layout(); + + final CommandFinder cf = CommandFinder.build() + .context( appModel.getContext() ) + .inputTriggerConfig( appModel.getKeymap().getConfig() ) + .keyConfigContexts( keyConfigContexts ) + .descriptionProvider( appModel.getWindowManager().getViewFactories().getCommandDescriptions() ) + .register( viewActions ) + .register( appModel.getModelActions() ) + .register( appModel.getProjectActions() ) + .register( appModel.getPlugins().getPluginActions() ) + .modificationListeners( appModel.getKeymap().updateListeners() ) + .parent( frame ) + .installOn( viewActions ); + cf.getDialog().setTitle( cf.getDialog().getTitle() + " - " + frame.getTitle() ); } private static FeatureGraphConfig getFeatureGraphConfig() diff --git a/src/main/java/org/mastodon/mamut/views/table/MamutViewTable.java b/src/main/java/org/mastodon/mamut/views/table/MamutViewTable.java index c3d94c306..8c57956cf 100644 --- a/src/main/java/org/mastodon/mamut/views/table/MamutViewTable.java +++ b/src/main/java/org/mastodon/mamut/views/table/MamutViewTable.java @@ -87,6 +87,7 @@ import org.mastodon.ui.coloring.TagSetGraphColorGenerator; import org.mastodon.ui.coloring.TrackGraphColorGenerator; import org.mastodon.ui.coloring.feature.FeatureColorModeManager; +import org.mastodon.ui.commandfinder.CommandFinder; import org.mastodon.ui.keymap.KeyConfigContexts; import org.mastodon.views.context.ContextChooser; import org.mastodon.views.context.HasContextChooser; @@ -183,6 +184,19 @@ protected MamutViewTable( final ProjectModel projectModel, final boolean selecti // Table actions. MastodonFrameViewActions.install( viewActions, this ); TableViewActions.install( viewActions, frame ); + final CommandFinder cf = CommandFinder.build() + .context( appModel.getContext() ) + .inputTriggerConfig( appModel.getKeymap().getConfig() ) + .keyConfigContexts( keyConfigContexts ) + .descriptionProvider( appModel.getWindowManager().getViewFactories().getCommandDescriptions() ) + .register( viewActions ) + .register( appModel.getModelActions() ) + .register( appModel.getProjectActions() ) + .register( appModel.getPlugins().getPluginActions() ) + .modificationListeners( appModel.getKeymap().updateListeners() ) + .parent( frame ) + .installOn( viewActions ); + cf.getDialog().setTitle( cf.getDialog().getTitle() + " - " + frame.getTitle() ); // Menus final ViewMenu menu = new ViewMenu( frame.getJMenuBar(), projectModel.getKeymap(), CONTEXTS ); diff --git a/src/main/java/org/mastodon/mamut/views/trackscheme/MamutBranchViewTrackScheme.java b/src/main/java/org/mastodon/mamut/views/trackscheme/MamutBranchViewTrackScheme.java index a0866ad0d..4b7bbd7d6 100644 --- a/src/main/java/org/mastodon/mamut/views/trackscheme/MamutBranchViewTrackScheme.java +++ b/src/main/java/org/mastodon/mamut/views/trackscheme/MamutBranchViewTrackScheme.java @@ -75,6 +75,7 @@ import org.mastodon.ui.coloring.GraphColorGeneratorAdapter; import org.mastodon.ui.coloring.HasColorBarOverlay; import org.mastodon.ui.coloring.HasColoringModel; +import org.mastodon.ui.commandfinder.CommandFinder; import org.mastodon.ui.keymap.KeyConfigContexts; import org.mastodon.views.trackscheme.LineageTreeLayout; import org.mastodon.views.trackscheme.LongEdgesLineageTreeLayout; @@ -122,7 +123,7 @@ protected MamutBranchViewTrackScheme( final TimepointModel timepointModel ) { super( appModel, trackSchemeGraphFactory.createViewGraph( appModel ), - new String[] { KeyConfigContexts.TRACKSCHEME } ); + new String[] { KeyConfigContexts.TRACKSCHEME, KeyConfigContexts.MASTODON } ); // TrackScheme options. final GraphColorGeneratorAdapter< BranchSpot, BranchLink, TrackSchemeVertex, TrackSchemeEdge > coloringAdapter = new GraphColorGeneratorAdapter<>( viewGraph.getVertexMap(), viewGraph.getEdgeMap() ); @@ -211,6 +212,21 @@ protected MamutBranchViewTrackScheme( frame.getTrackschemePanel().graphChanged(); } ); + // Command finder. + final CommandFinder cf = CommandFinder.build() + .context( appModel.getContext() ) + .inputTriggerConfig( appModel.getKeymap().getConfig() ) + .descriptionProvider( appModel.getWindowManager().getViewFactories().getCommandDescriptions() ) + .keyConfigContexts( keyConfigContexts ) + .register( viewActions ) + .register( appModel.getModelActions() ) + .register( appModel.getProjectActions() ) + .register( appModel.getPlugins().getPluginActions() ) + .modificationListeners( appModel.getKeymap().updateListeners() ) + .parent( frame ) + .installOn( viewActions ); + cf.getDialog().setTitle( cf.getDialog().getTitle() + " - " + frame.getTitle() ); + // Menus. final ViewMenu menu = new ViewMenu( frame.getJMenuBar(), appModel.getKeymap(), keyConfigContexts ); final ActionMap actionMap = frame.getKeybindings().getConcatenatedActionMap(); diff --git a/src/main/java/org/mastodon/mamut/views/trackscheme/MamutViewTrackScheme.java b/src/main/java/org/mastodon/mamut/views/trackscheme/MamutViewTrackScheme.java index 18b17c52c..318072f40 100644 --- a/src/main/java/org/mastodon/mamut/views/trackscheme/MamutViewTrackScheme.java +++ b/src/main/java/org/mastodon/mamut/views/trackscheme/MamutViewTrackScheme.java @@ -73,6 +73,7 @@ import org.mastodon.ui.coloring.GraphColorGeneratorAdapter; import org.mastodon.ui.coloring.HasColorBarOverlay; import org.mastodon.ui.coloring.HasColoringModel; +import org.mastodon.ui.commandfinder.CommandFinder; import org.mastodon.ui.keymap.KeyConfigContexts; import org.mastodon.views.context.ContextChooser; import org.mastodon.views.context.HasContextChooser; @@ -201,6 +202,21 @@ public MamutViewTrackScheme( final ProjectModel appModel ) frame.getTrackschemePanel().getNavigationBehaviours().install( viewBehaviours ); frame.getTrackschemePanel().getTransformEventHandler().install( viewBehaviours ); + // Command finder. + final CommandFinder cf = CommandFinder.build() + .context( appModel.getContext() ) + .inputTriggerConfig( appModel.getKeymap().getConfig() ) + .keyConfigContexts( keyConfigContexts ) + .descriptionProvider( appModel.getWindowManager().getViewFactories().getCommandDescriptions() ) + .register( viewActions ) + .register( appModel.getModelActions() ) + .register( appModel.getProjectActions() ) + .register( appModel.getPlugins().getPluginActions() ) + .modificationListeners( appModel.getKeymap().updateListeners() ) + .parent( frame ) + .installOn( viewActions ); + cf.getDialog().setTitle( cf.getDialog().getTitle() + " - " + frame.getTitle() ); + final ViewMenu menu = new ViewMenu( this ); final ActionMap actionMap = frame.getKeybindings().getConcatenatedActionMap(); diff --git a/src/main/java/org/mastodon/ui/commandfinder/CommandFinder.java b/src/main/java/org/mastodon/ui/commandfinder/CommandFinder.java new file mode 100644 index 000000000..8a50d0ee7 --- /dev/null +++ b/src/main/java/org/mastodon/ui/commandfinder/CommandFinder.java @@ -0,0 +1,311 @@ +package org.mastodon.ui.commandfinder; + +import java.awt.event.ActionEvent; +import java.awt.event.ComponentAdapter; +import java.awt.event.ComponentEvent; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; + +import javax.swing.Action; +import javax.swing.ActionMap; +import javax.swing.InputMap; +import javax.swing.JComponent; +import javax.swing.JDialog; +import javax.swing.JFrame; + +import org.mastodon.app.ui.CloseWindowActions; +import org.mastodon.ui.keymap.KeyConfigContexts; +import org.mastodon.ui.keymap.KeyConfigScopes; +import org.scijava.Context; +import org.scijava.listeners.Listeners; +import org.scijava.plugin.Plugin; +import org.scijava.ui.behaviour.io.InputTriggerConfig; +import org.scijava.ui.behaviour.io.gui.Command; +import org.scijava.ui.behaviour.io.gui.CommandDescriptionProvider; +import org.scijava.ui.behaviour.io.gui.CommandDescriptions; +import org.scijava.ui.behaviour.io.gui.CommandDescriptionsBuilder; +import org.scijava.ui.behaviour.util.Actions; + +import bdv.tools.ToggleDialogAction; +import bdv.ui.keymap.Keymap.UpdateListener; + +public class CommandFinder +{ + + public static final String SHOW_COMMAND_FINDER = "show command finder"; + + private static final String[] SHOW_COMMAND_FINDER_KEYS = new String[] { "ctrl shift F", "meta shift F" }; + + private final JDialog dialog; + + private final List< Actions > acs; + + private final CommandFinderPanel gui; + + public static Builder build() + { + return new Builder(); + } + + private CommandFinder( + final List< Actions > acs, + final InputTriggerConfig config, + final Map< Command, String > commandMap, + final String[] keyConfigContexts, + final JFrame parent ) + { + this.acs = acs; + this.dialog = new JDialog( parent, "Command finder" ); + + final Consumer< String > runner = ( actionName ) -> run( actionName ); + this.gui = new CommandFinderPanel( runner, commandMap, config ); + + dialog.getContentPane().add( gui ); + dialog.pack(); + dialog.setLocationByPlatform( true ); + dialog.setLocationRelativeTo( null ); + // Give focus to text after being made visible. + dialog.addComponentListener( new ComponentAdapter() + { + @Override + public void componentShown( final ComponentEvent e ) + { + gui.textFieldFilter.requestFocusInWindow(); + } + } ); + // Close with escape. + final ActionMap am = dialog.getRootPane().getActionMap(); + final InputMap im = dialog.getRootPane().getInputMap( JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT ); + final Actions actionsDialog = new Actions( im, am, null, keyConfigContexts ); + CloseWindowActions.install( actionsDialog, dialog ); + } + + public CommandFinderPanel getGui() + { + return gui; + } + + public JDialog getDialog() + { + return dialog; + } + + /** + * Runs the 'first' action of behavior we can find with the specified name. + * + * @param actionName + * the action or behavior name. + */ + private void run( final String actionName ) + { + for ( final Actions ac : acs ) + { + final Action action = ac.getActionMap().get( actionName ); + if ( action != null ) + { + final ActionEvent event = new ActionEvent( dialog, 0, actionName ); + action.actionPerformed( event ); + return; + } + } + } + + /* + * Command descriptions for all provided commands + */ + @Plugin( type = CommandDescriptionProvider.class ) + public static class Descriptions extends CommandDescriptionProvider + { + public Descriptions() + { + super( KeyConfigScopes.MASTODON, KeyConfigContexts.MASTODON ); + } + + @Override + public void getCommandDescriptions( final CommandDescriptions descriptions ) + { + descriptions.add( SHOW_COMMAND_FINDER, SHOW_COMMAND_FINDER_KEYS, "Show the command finder dialog." ); + } + } + + public static class Builder + { + + private final List< Actions > acs = new ArrayList<>(); + + private final List< CommandDescriptionProvider > descriptionProviders = new ArrayList<>(); + + private JFrame parent; + + private final Set< String > keyConfigContexts = new HashSet<>(); + + private InputTriggerConfig config; + + private Context context; + + private Listeners< UpdateListener > updateListeners; + + + public Builder context( final Context context ) + { + this.context = context; + return this; + } + + public Builder inputTriggerConfig( final InputTriggerConfig config ) + { + this.config = config; + return this; + } + + public Builder register( final Actions actions ) + { + this.acs.add( actions ); + return this; + } + + public Builder parent( final JFrame parent ) + { + this.parent = parent; + return this; + } + + public Builder keyConfigContext( final String kcc ) + { + this.keyConfigContexts.add( kcc ); + return this; + } + + public Builder keyConfigContexts( final String[] keyConfigContexts ) + { + for ( final String kcc : keyConfigContexts ) + this.keyConfigContexts.add( kcc ); + return this; + } + + public CommandFinder get() + { + if ( config == null ) + throw new IllegalArgumentException( "The InputTriggerConfig cannot be null." ); + if ( context == null ) + throw new IllegalArgumentException( "The Context cannot be null." ); + + final Map< Command, String > commandDescriptions = buildCommandDescriptions(); + final CommandFinder cf = new CommandFinder( + acs, + config, + commandDescriptions, + keyConfigContexts.toArray( new String[] {} ), + parent ); + // Refresh the command list it it changes. + if ( updateListeners != null ) + { + updateListeners.add( () -> { + final Map< Command, String > cds = buildCommandDescriptions(); + cf.getGui().setCommandDescriptions( cds ); + } ); + } + return cf; + } + + public Builder modificationListeners( final Listeners< UpdateListener > updateListeners ) + { + this.updateListeners = updateListeners; + return this; + } + + public Builder descriptionProvider( final CommandDescriptionProvider descriptionsProvider ) + { + this.descriptionProviders.add( descriptionsProvider ); + return this; + } + + public CommandFinder installOn( final Actions installOn ) + { + final CommandFinder cf = get(); + installOn.namedAction( new ToggleDialogAction( SHOW_COMMAND_FINDER, cf.dialog ), SHOW_COMMAND_FINDER_KEYS ); + register( installOn ); + cf.getGui().setCommandDescriptions( buildCommandDescriptions() ); + return cf; + } + + /** + * Discovers and build the command vs description map. + *
+ * Important: in this implementation of a command finder, derived from + * the keymap editor, that commands are listed and filtered from the + * command descriptions. Which means that only the commands that have a + * description, provided with a CommandDescriptionProvider, will appear + * in the command list. + * + * @param actionMap + * the command descriptions will be filtered to only include + * the commands present in the specified action map. + * @param keyConfigContexts + * the command descriptions will be filtered to only include + * those with the context in the specified array. + * @return the command descriptions map as a new map. + */ + private Map< Command, String > buildCommandDescriptions() + { + // Copy and sort key contexts. + final String[] contexts = Arrays.copyOf( keyConfigContexts.toArray( new String[] {} ), keyConfigContexts.size() ); + Arrays.sort( contexts ); + + final CommandDescriptionsBuilder builder = new CommandDescriptionsBuilder(); + context.inject( builder ); + builder.discoverProviders(); + for ( final CommandDescriptionProvider cdp : descriptionProviders ) + builder.addManually( cdp, contexts ); + final CommandDescriptions cd = builder.build(); + final Map< Command, String > map = cd.createCommandDescriptionsMap(); + + + // Create new command map, filtered. + final Map< Command, String > filteredMap = new HashMap<>(); + + /* + * Build array of keys (command name) that are in one of the actions + * or behaviors we were build with. + */ + + // Loop over actions. + final Set< String > allKeySet = new HashSet<>(); + for ( final Actions ac : acs ) + { + // Build list of commands in the action map. + final ActionMap actionMap = ac.getActionMap(); + final Object[] objs = actionMap.allKeys(); + if ( objs != null ) + for ( int i = 0; i < objs.length; i++ ) + allKeySet.add( ( String ) objs[ i ] ); + } + final String[] allKeys = allKeySet.toArray( new String[] {} ); + Arrays.sort( allKeys ); + + /* + * Loop over all command descriptions, and retain those 1/ which + * have a context that we manage 2/ which are in one of the action + * map of behavior map that was provided to us. + */ + for ( final Command command : map.keySet() ) + { + if ( Arrays.binarySearch( contexts, command.getContext() ) < 0 ) + continue; // not in our contexts. + + if ( Arrays.binarySearch( allKeys, command.getName() ) < 0 ) + continue; // not a command we know. + + // Add it. + filteredMap.put( command, map.get( command ) ); + } + return filteredMap; + } + } +} diff --git a/src/main/java/org/mastodon/ui/commandfinder/CommandFinderPanel.java b/src/main/java/org/mastodon/ui/commandfinder/CommandFinderPanel.java new file mode 100644 index 000000000..e5d5d80a3 --- /dev/null +++ b/src/main/java/org/mastodon/ui/commandfinder/CommandFinderPanel.java @@ -0,0 +1,777 @@ +package org.mastodon.ui.commandfinder; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.FlowLayout; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.awt.KeyboardFocusManager; +import java.awt.event.KeyAdapter; +import java.awt.event.KeyEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Set; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTable; +import javax.swing.JTextArea; +import javax.swing.JTextField; +import javax.swing.KeyStroke; +import javax.swing.ListSelectionModel; +import javax.swing.RowFilter; +import javax.swing.ScrollPaneConstants; +import javax.swing.SwingUtilities; +import javax.swing.border.EmptyBorder; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.event.ListSelectionEvent; +import javax.swing.event.ListSelectionListener; +import javax.swing.table.AbstractTableModel; +import javax.swing.table.TableCellRenderer; +import javax.swing.table.TableRowSorter; + +import org.apache.commons.lang3.StringUtils; +import org.scijava.listeners.Listeners; +import org.scijava.ui.behaviour.InputTrigger; +import org.scijava.ui.behaviour.io.InputTriggerConfig; +import org.scijava.ui.behaviour.io.gui.Command; +import org.scijava.ui.behaviour.io.gui.TagPanelEditor; +import org.scijava.ui.behaviour.io.gui.VisualEditorPanel.ConfigChangeListener; +import org.scijava.ui.behaviour.util.RunnableAction; + +public class CommandFinderPanel extends JPanel +{ + + private static final long serialVersionUID = 1L; + + private final Consumer< String > runAction; + + private final InputTriggerConfig config; + + private final Map< Command, String > actionDescriptions; + + private final Set< Command > commands; + + private final Listeners.List< ConfigChangeListener > modelChangedListeners; + + final JTextField textFieldFilter; + + private final JPanel panelEditor; + + private final JLabel labelCommandName; + + private final JTextArea textAreaDescription; + + private final JButton btnApply; + + private final JTable tableBindings; + + private TableRowSorter< MyTableModel > tableRowSorter; + + private MyTableModel tableModel; + + private JLabel keybindingEditor; + + public CommandFinderPanel( + final Consumer< String > runAction, + final Map< Command, String > commandMap, + final InputTriggerConfig config + ) + { + this.runAction = runAction; + this.config = config; + this.actionDescriptions = new HashMap<>( commandMap ); + this.commands = new HashSet<>( actionDescriptions.keySet() ); + this.modelChangedListeners = new Listeners.SynchronizedList<>(); + + /* + * GUI + */ + + setLayout( new BorderLayout( 0, 0 ) ); + + final JPanel panelFilter = new JPanel(); + add( panelFilter, BorderLayout.NORTH ); + panelFilter.setLayout( new BoxLayout( panelFilter, BoxLayout.X_AXIS ) ); + + final Component horizontalStrut = Box.createHorizontalStrut( 5 ); + panelFilter.add( horizontalStrut ); + + final JLabel lblFilter = new JLabel( "Filter:" ); + lblFilter.setToolTipText( "Filter on command names. Accept regular expressions." ); + lblFilter.setAlignmentX( Component.CENTER_ALIGNMENT ); + panelFilter.add( lblFilter ); + + final Component horizontalStrut_1 = Box.createHorizontalStrut( 5 ); + panelFilter.add( horizontalStrut_1 ); + + textFieldFilter = new JTextField(); + panelFilter.add( textFieldFilter ); + textFieldFilter.setColumns( 10 ); + // Run action when the user presses enter. + textFieldFilter.addActionListener( e -> runFromFilter() ); + // Filter when a key is pressed. + textFieldFilter.getDocument().addDocumentListener( new DocumentListener() + { + + @Override + public void removeUpdate( final DocumentEvent e ) + { + filterRows(); + } + + @Override + public void insertUpdate( final DocumentEvent e ) + { + filterRows(); + } + + @Override + public void changedUpdate( final DocumentEvent e ) + { + filterRows(); + } + } ); + // Move to the list with UP and DOWN array key. + textFieldFilter.addKeyListener( new KeyAdapter() + { + @Override + public void keyPressed( final KeyEvent e ) + { + final int keyCode = e.getKeyCode(); + if ( keyCode == KeyEvent.VK_UP ) + goToLastInList(); + else if ( keyCode == KeyEvent.VK_DOWN ) + goToFirstInList(); + } + } ); + + panelEditor = new JPanel(); + add( panelEditor, BorderLayout.SOUTH ); + panelEditor.setLayout( new BorderLayout( 0, 0 ) ); + + final JPanel panelCommandEditor = new JPanel(); + panelEditor.add( panelCommandEditor, BorderLayout.CENTER ); + final GridBagLayout gbl_panelCommandEditor = new GridBagLayout(); + gbl_panelCommandEditor.rowHeights = new int[] { 0, 0, 0, 0, 60 }; + gbl_panelCommandEditor.columnWidths = new int[] { 30, 100 }; + gbl_panelCommandEditor.columnWeights = new double[] { 0.0, 1.0 }; + gbl_panelCommandEditor.rowWeights = new double[] { 0.0, 0.0, 0.0, 0.0, 0.0 }; + panelCommandEditor.setLayout( gbl_panelCommandEditor ); + + final JLabel lblName = new JLabel( "Name:" ); + final GridBagConstraints gbc_lblName = new GridBagConstraints(); + gbc_lblName.insets = new Insets( 5, 5, 5, 5 ); + gbc_lblName.anchor = GridBagConstraints.WEST; + gbc_lblName.gridx = 0; + gbc_lblName.gridy = 0; + panelCommandEditor.add( lblName, gbc_lblName ); + + this.labelCommandName = new JLabel(); + final GridBagConstraints gbc_labelActionName = new GridBagConstraints(); + gbc_labelActionName.anchor = GridBagConstraints.WEST; + gbc_labelActionName.insets = new Insets( 5, 5, 5, 0 ); + gbc_labelActionName.gridx = 1; + gbc_labelActionName.gridy = 0; + panelCommandEditor.add( labelCommandName, gbc_labelActionName ); + + final JLabel lblBinding = new JLabel( "Binding:" ); + final GridBagConstraints gbc_lblBinding = new GridBagConstraints(); + gbc_lblBinding.anchor = GridBagConstraints.WEST; + gbc_lblBinding.insets = new Insets( 5, 5, 5, 5 ); + gbc_lblBinding.gridx = 0; + gbc_lblBinding.gridy = 1; + panelCommandEditor.add( lblBinding, gbc_lblBinding ); + + this.keybindingEditor = new JLabel(); + final GridBagConstraints gbc_textFieldBinding = new GridBagConstraints(); + gbc_textFieldBinding.insets = new Insets( 5, 5, 5, 5 ); + gbc_textFieldBinding.fill = GridBagConstraints.HORIZONTAL; + gbc_textFieldBinding.gridx = 1; + gbc_textFieldBinding.gridy = 1; + panelCommandEditor.add( keybindingEditor, gbc_textFieldBinding ); + + final JLabel lblDescription = new JLabel( "Description:" ); + final GridBagConstraints gbc_lblDescription = new GridBagConstraints(); + gbc_lblDescription.insets = new Insets( 5, 5, 5, 5 ); + gbc_lblDescription.anchor = GridBagConstraints.NORTHWEST; + gbc_lblDescription.gridx = 0; + gbc_lblDescription.gridy = 4; + panelCommandEditor.add( lblDescription, gbc_lblDescription ); + + final JScrollPane scrollPaneDescription = new JScrollPane(); + scrollPaneDescription.setOpaque( false ); + scrollPaneDescription.setHorizontalScrollBarPolicy( ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER ); + final GridBagConstraints gbc_scrollPaneDescription = new GridBagConstraints(); + gbc_scrollPaneDescription.insets = new Insets( 5, 5, 5, 5 ); + gbc_scrollPaneDescription.fill = GridBagConstraints.BOTH; + gbc_scrollPaneDescription.gridx = 1; + gbc_scrollPaneDescription.gridy = 4; + panelCommandEditor.add( scrollPaneDescription, gbc_scrollPaneDescription ); + + textAreaDescription = new JTextArea(); + textAreaDescription.setRows( 3 ); + textAreaDescription.setFont( getFont().deriveFont( getFont().getSize2D() - 1f ) ); + textAreaDescription.setOpaque( false ); + textAreaDescription.setWrapStyleWord( true ); + textAreaDescription.setEditable( false ); + textAreaDescription.setLineWrap( true ); + textAreaDescription.setFocusable( false ); + scrollPaneDescription.setViewportView( textAreaDescription ); + + final JPanel panelButtons = new JPanel(); + panelEditor.add( panelButtons, BorderLayout.SOUTH ); + final FlowLayout flowLayout = ( FlowLayout ) panelButtons.getLayout(); + flowLayout.setAlignment( FlowLayout.TRAILING ); + + this.btnApply = new JButton( "Run" ); + btnApply.setToolTipText( "Run the selected command." ); + panelButtons.add( btnApply ); + + final JScrollPane scrollPane = new JScrollPane(); + scrollPane.setVerticalScrollBarPolicy( ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS ); + scrollPane.setHorizontalScrollBarPolicy( ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER ); + add( scrollPane, BorderLayout.CENTER ); + + tableBindings = new JTable() + { + private static final long serialVersionUID = 1L; + + @Override + public void updateUI() + { + super.updateUI(); + setRowHeight( ( int ) ( getFontMetrics( getFont() ).getHeight() * 1.5 ) ); + } + }; + tableBindings.setSelectionMode( ListSelectionModel.SINGLE_SELECTION ); + tableBindings.setFillsViewportHeight( true ); + tableBindings.setAutoResizeMode( JTable.AUTO_RESIZE_SUBSEQUENT_COLUMNS ); + tableBindings.getSelectionModel().addListSelectionListener( new ListSelectionListener() + { + @Override + public void valueChanged( final ListSelectionEvent e ) + { + if ( e.getValueIsAdjusting() ) + return; + updateEditors(); + } + } ); + tableBindings.setFocusTraversalKeys( KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS, null ); + tableBindings.setFocusTraversalKeys( KeyboardFocusManager.BACKWARD_TRAVERSAL_KEYS, null ); + + // Run when the user press enter and a row is selected + final KeyStroke enter = KeyStroke.getKeyStroke( KeyEvent.VK_ENTER, 0 ); + final String solve = "Run command"; + tableBindings.getInputMap( JTable.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT ).put( enter, solve ); + tableBindings.getActionMap().put( solve, new RunnableAction( solve, this::runSelection ) ); + + // Run when the user double-clicks on a row + tableBindings.addMouseListener( new MouseAdapter() + { + @Override + public void mouseClicked( final MouseEvent e ) + { + if ( e.getClickCount() == 2 ) + runSelection(); + } + } ); + + // Run when the user presses the button + btnApply.addActionListener( ( e ) -> runSelection() ); + + configToModel(); + tableBindings.getRowSorter().toggleSortOrder( 0 ); + if ( tableBindings.getRowCount() > 0 ) + tableBindings.getSelectionModel().setSelectionInterval( 0, 0 ); + + scrollPane.setViewportView( tableBindings ); + } + + private void goToFirstInList() + { + selectAndShowViewRow( 0 ); + } + + private void goToLastInList() + { + final int nFiltered = tableBindings.getRowSorter().getViewRowCount(); + selectAndShowViewRow( nFiltered - 1 ); + } + + private void selectAndShowViewRow( final int row ) + { + tableBindings.getSelectionModel().setSelectionInterval( row, row ); + tableBindings.requestFocusInWindow(); + tableBindings.scrollRectToVisible( tableBindings.getCellRect( row, row, true ) ); + } + + private void runFromFilter() + { + final int nFiltered = tableBindings.getRowSorter().getViewRowCount(); + if ( nFiltered != 1 ) + return; + + runRow( 0 ); + } + + private void runSelection() + { + final int viewRow = tableBindings.getSelectedRow(); + if ( viewRow < 0 ) + { + SwingUtilities.getWindowAncestor( this ).setVisible( false ); + return; + } + runRow( viewRow ); + } + + private void runRow( final int viewRow ) + { + final int modelRow = tableBindings.convertRowIndexToModel( viewRow ); + final MyTableRow row = tableModel.rows.get( modelRow ); + final String actionName = row.getName(); + + runAction.accept( actionName ); + SwingUtilities.getWindowAncestor( this ).setVisible( false ); + } + + private void updateEditors() + { + final int viewRow = tableBindings.getSelectedRow(); + if ( viewRow < 0 ) + { + labelCommandName.setText( "" ); + textAreaDescription.setText( "" ); + keybindingEditor.setText( InputTrigger.NOT_MAPPED.toString() ); + return; + } + + final int modelRow = tableBindings.convertRowIndexToModel( viewRow ); + final MyTableRow row = tableModel.rows.get( modelRow ); + final String action = row.getName(); + final List< InputTrigger > triggers = row.getTrigger(); + final List< String > contexts = row.getContexts(); + final String description = actionDescriptions.get( new Command( action, contexts.get( 0 ) ) ); + labelCommandName.setText( action ); + keybindingEditor.setText( StringUtils.join( triggers, ", " ) ); + textAreaDescription.setText( description ); + textAreaDescription.setCaretPosition( 0 ); + } + + public void setCommandDescriptions( final Map< Command, String > cds ) + { + actionDescriptions.clear(); + commands.clear(); + actionDescriptions.putAll( cds ); + commands.addAll( actionDescriptions.keySet() ); + configToModel(); + } + + private void configToModel() + { + tableModel = new MyTableModel( commands, config ); + tableBindings.setModel( tableModel ); + + tableRowSorter = new TableRowSorter<>( tableModel ); + tableRowSorter.setComparator( 1, InputTriggersComparator ); + tableBindings.setRowSorter( tableRowSorter ); + filterRows(); + + // Renderers. + tableBindings.getColumnModel().getColumn( 1 ).setCellRenderer( new MyBindingsRenderer() ); + tableBindings.getColumnModel().getColumn( 2 ).setCellRenderer( new MyContextsRenderer( Collections.emptyList() ) ); + + // Notify listeners. + notifyListeners(); + } + + private void notifyListeners() + { + modelChangedListeners.list.forEach( ConfigChangeListener::configChanged ); + } + + private void filterRows() + { + final String regex = textFieldFilter.getText(); + final Pattern pattern = Pattern.compile( regex, Pattern.CASE_INSENSITIVE ); + final Matcher matcher = pattern.matcher( "" ); + final RowFilter< MyTableModel, Integer > rf = new RowFilter< MyTableModel, Integer >() + { + + @Override + public boolean include( final Entry< ? extends MyTableModel, ? extends Integer > entry ) + { + int count = entry.getValueCount(); + while ( --count >= 0 ) + { + matcher.reset( entry.getStringValue( count ) ); + if ( matcher.find() ) + { return true; } + } + return false; + } + }; + tableRowSorter.setRowFilter( rf ); + } + + private static class MyTableModel extends AbstractTableModel + { + + private static final long serialVersionUID = 1L; + + private static final String[] TABLE_HEADERS = new String[] { "Command", "Binding", "Contexts" }; + + private final List< MyTableRow > rows; + + private final Set< Command > allCommands; + + public MyTableModel( final Set< Command > commands, final InputTriggerConfig config ) + { + rows = new ArrayList<>(); + allCommands = commands; + for ( final Command command : commands ) + { + final Set< InputTrigger > inputs = config.getInputs( command.getName(), command.getContext() ); + for ( final InputTrigger input : inputs ) + rows.add( new MyTableRow( command.getName(), input, command.getContext() ) ); + } + addMissingRows(); + } + + /** + * In the given list of {@code rows}, find and merge rows with the same + * action name and trigger, but different contexts. + * + * @param rows + * list of rows to modify. + */ + private void mergeRows( final List< MyTableRow > rows ) + { + final List< MyTableRow > rowsUnmerged = new ArrayList<>( rows ); + rows.clear(); + + rowsUnmerged.sort( MyTableRowComparator ); + + for ( int i = 0; i < rowsUnmerged.size(); ) + { + final MyTableRow rowA = rowsUnmerged.get( i ); + int j = i + 1; + while ( j < rowsUnmerged.size() && MyTableRowComparator.compare( rowsUnmerged.get( j ), rowA ) == 0 ) + ++j; + + // Merge contexts. + final Set< String > contexts = new HashSet<>(); + for ( int k = i; k < j; ++k ) + contexts.addAll( rowsUnmerged.get( k ).getContexts() ); + final List< String > contextsList = new ArrayList<>( contexts ); + contextsList.sort( null ); + + // Merge triggers. + final Set< InputTrigger > triggers = new HashSet<>(); + for ( int k = i; k < j; ++k ) + triggers.addAll( rowsUnmerged.get( k ).getTrigger() ); + final List< InputTrigger > triggersList = new ArrayList<>( triggers ); + triggersList.sort( InputTriggerComparator ); + + rows.add( new MyTableRow( rowA.getName(), triggersList, contextsList ) ); + + i = j; + } + } + + /** + * Add {@code NOT_MAPPED} rows for (name, context) pairs in + * {@link #allCommands} that are not otherwise covered. Then + * {@link #mergeRows()}. + * + * If any changes are made, {@code fireTableDataChanged} is fired. + * + * @return true, if changes were made. + */ + private boolean addMissingRows() + { + final ArrayList< MyTableRow > copy = new ArrayList<>( rows ); + addMissingRows( rows ); + if ( !copy.equals( rows ) ) + { + this.fireTableDataChanged(); + return true; + } + return false; + } + + /** + * In the given list of {@code rows}, add {@code NOT_MAPPED} rows for + * (name, context) pairs in {@link #allCommands} that are not otherwise + * covered. Then {@link #mergeRows(List)}. + * + * @param rows + * list of rows to modify. + */ + private void addMissingRows( final List< MyTableRow > rows ) + { + final ArrayList< Command > missingCommands = new ArrayList<>(); + for ( final Command command : allCommands ) + { + boolean found = false; + for ( final MyTableRow row : rows ) + { + if ( row.getName().equals( command.getName() ) && row.getContexts().contains( command.getContext() ) ) + { + found = true; + break; + } + } + if ( !found ) + missingCommands.add( command ); + } + + for ( final Command command : missingCommands ) + rows.add( new MyTableRow( command.getName(), InputTrigger.NOT_MAPPED, command.getContext() ) ); + + mergeRows( rows ); + } + + @Override + public int getRowCount() + { + return rows.size(); + } + + @Override + public int getColumnCount() + { + return 3; + } + + @Override + public Object getValueAt( final int rowIndex, final int columnIndex ) + { + switch ( columnIndex ) + { + case 0: + return rows.get( rowIndex ).getName(); + case 1: + return rows.get( rowIndex ).getTrigger(); + case 2: + return rows.get( rowIndex ).getContexts(); + default: + throw new NoSuchElementException( "Cannot access column " + columnIndex + " in this model." ); + } + } + + @Override + public String getColumnName( final int column ) + { + return TABLE_HEADERS[ column ]; + } + } + + private static class MyTableRow + { + private final String name; + + private final List< InputTrigger > triggers; + + private final List< String > contexts; + + public MyTableRow( final String name, final InputTrigger trigger, final String context ) + { + this( name, Collections.singletonList( trigger ), Collections.singletonList( context ) ); + } + + public MyTableRow( final String name, final List< InputTrigger > triggers, final Collection< String > contexts ) + { + this.name = name; + this.triggers = triggers; + this.contexts = new ArrayList<>( contexts ); + } + + public String getName() + { + return name; + } + + public List< InputTrigger > getTrigger() + { + return triggers; + } + + public List< String > getContexts() + { + return contexts; + } + + @Override + public boolean equals( final Object o ) + { + if ( this == o ) + return true; + if ( o == null || getClass() != o.getClass() ) + return false; + + final MyTableRow that = ( MyTableRow ) o; + + if ( !name.equals( that.name ) ) + return false; + if ( !triggers.equals( that.triggers ) ) + return false; + return contexts.equals( that.contexts ); + } + + @Override + public int hashCode() + { + int result = name.hashCode(); + result = 31 * result + triggers.hashCode(); + result = 31 * result + contexts.hashCode(); + return result; + } + + @Override + public String toString() + { + return "MyTableRow{" + + "name='" + name + '\'' + + ", trigger=" + triggers + + ", contexts=" + contexts + + '}'; + } + } + + private static final Comparator< MyTableRow > MyTableRowComparator = new Comparator< MyTableRow >() + { + @Override + public int compare( final MyTableRow o1, final MyTableRow o2 ) + { + final int cn = o1.name.compareTo( o2.name ); + if ( cn != 0 ) + return cn; + + return 0; + } + }; + + private static final Comparator< InputTrigger > InputTriggerComparator = new Comparator< InputTrigger >() + { + @Override + public int compare( final InputTrigger o1, final InputTrigger o2 ) + { + if ( o1 == InputTrigger.NOT_MAPPED ) + return 1; + if ( o2 == InputTrigger.NOT_MAPPED ) + return -1; + return o1.toString().compareTo( o2.toString() ); + } + }; + + private static final Comparator< List< InputTrigger > > InputTriggersComparator = new Comparator< List< InputTrigger > >() + { + @Override + public int compare( final List< InputTrigger > o1, final List< InputTrigger > o2 ) + { + if ( o1.equals( Collections.singletonList( InputTrigger.NOT_MAPPED ) ) ) + return 1; + if ( o2.equals( Collections.singletonList( InputTrigger.NOT_MAPPED ) ) ) + return -1; + + return StringUtils.join( o1, ", " ).compareTo( StringUtils.join( o2, ", " ) ); + } + }; + + private static final class MyBindingsRenderer extends MultiInputTriggerPanelEditor implements TableCellRenderer + { + + private static final long serialVersionUID = 1L; + + public MyBindingsRenderer() + { + super(); + } + + @Override + public void updateUI() + { + super.updateUI(); + setBorder( new EmptyBorder( 0, 0, 0, 0 ) ); + } + + @Override + public Component getTableCellRendererComponent( final JTable table, final Object value, final boolean isSelected, final boolean hasFocus, final int row, final int column ) + { + setForeground( isSelected ? table.getSelectionForeground() : table.getForeground() ); + setBackground( isSelected ? table.getSelectionBackground() : table.getBackground() ); + + @SuppressWarnings( "unchecked" ) + final List< InputTrigger > inputs = ( List< InputTrigger > ) value; + if ( null != inputs ) + { + setInputTriggers( inputs ); + final String val = StringUtils.join( inputs, ", " ); + setToolTipText( val ); + } + else + { + setInputTriggers( Collections.singletonList( InputTrigger.NOT_MAPPED ) ); + setToolTipText( "No binding" ); + } + return this; + } + } + + private final class MyContextsRenderer extends TagPanelEditor implements TableCellRenderer + { + + private static final long serialVersionUID = 1L; + + public MyContextsRenderer( final Collection< String > tags ) + { + super( tags, false ); + } + + @Override + public void updateUI() + { + super.updateUI(); + setBorder( new EmptyBorder( 0, 0, 0, 0 ) ); + } + + @Override + public Component getTableCellRendererComponent( final JTable table, final Object value, final boolean isSelected, final boolean hasFocus, final int row, final int column ) + { + setForeground( isSelected ? table.getSelectionForeground() : table.getForeground() ); + setBackground( isSelected ? table.getSelectionBackground() : table.getBackground() ); + + @SuppressWarnings( "unchecked" ) + final List< String > contexts = value != null + ? ( List< String > ) value + : Collections.emptyList(); + if ( contexts.isEmpty() ) + setBackground( Color.PINK ); + setAcceptableTags( contexts ); + setTags( contexts ); + setToolTipText( contexts.toString() ); + return this; + } + } +} diff --git a/src/main/java/org/mastodon/ui/commandfinder/MultiInputTriggerPanelEditor.java b/src/main/java/org/mastodon/ui/commandfinder/MultiInputTriggerPanelEditor.java new file mode 100644 index 000000000..d63fcfe3a --- /dev/null +++ b/src/main/java/org/mastodon/ui/commandfinder/MultiInputTriggerPanelEditor.java @@ -0,0 +1,420 @@ +/*- + * #%L + * Configurable key and mouse event handling + * %% + * Copyright (C) 2015 - 2023 Max Planck Institute of Molecular Cell Biology + * and Genetics. + * %% + * 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.mastodon.ui.commandfinder; + +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Font; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JTextField; +import javax.swing.UIManager; +import javax.swing.border.EmptyBorder; + +import org.scijava.ui.behaviour.InputTrigger; +import org.scijava.ui.behaviour.io.gui.RoundBorder; + +public class MultiInputTriggerPanelEditor extends JPanel +{ + + @FunctionalInterface + public static interface InputTriggerChangeListener + { + public void inputTriggerChanged(); + } + + private static final long serialVersionUID = 1L; + + private final List< KeyItem > keyItems; + + private final JTextField textField; + + private List< InputTrigger > triggers = Collections.singletonList( InputTrigger.NOT_MAPPED ); + + public MultiInputTriggerPanelEditor() + { + this.keyItems = new ArrayList<>(); + + setPreferredSize( new Dimension( 400, 26 ) ); + setMinimumSize( new Dimension( 26, 26 ) ); + setLayout( new BoxLayout( this, BoxLayout.LINE_AXIS ) ); + + this.textField = new JTextField(); + textField.setColumns( 10 ); + textField.setBorder( new EmptyBorder( 0, 0, 0, 0 ) ); + textField.setOpaque( false ); + textField.setEditable( false ); + + add( textField ); + add( Box.createHorizontalGlue() ); + } + + @Override + public void updateUI() + { + super.updateUI(); + setBorder( UIManager.getBorder( "TextField.border" ) ); + setBackground( UIManager.getColor( "TextField.background" ) ); + } + + public void setInputTriggers( final List< InputTrigger > triggers ) + { + this.triggers = triggers; + regenKeyPanels(); + } + + public List< InputTrigger > getInputTrigger() + { + return triggers; + } + + private void regenKeyPanels() + { + // Clear + for ( final KeyItem keyItem : keyItems ) + remove( keyItem ); + keyItems.clear(); + + final List< String[] > tokenList = new ArrayList<>( triggers.size() ); + for ( final InputTrigger trigger : triggers ) + { + if ( trigger.equals( InputTrigger.NOT_MAPPED ) ) + tokenList.add( null ); + else + tokenList.add( trigger.toString().split( " " ) ); + } + + int n = 0; + for ( int i = 0; i < tokenList.size(); i++ ) + { + final String[] tokens = tokenList.get( i ); + if ( tokens == null ) + continue; + if ( n > 0 ) + { + // Separator. + final KeyItem tagp = new SeparatorKeyItem(); + keyItems.add( tagp ); + add( tagp, getComponentCount() - 2 ); + } + + sortTokens( tokens ); + for ( final String key : tokens ) + { + final KeyItem tagp = new KeyItem( key, true ); + keyItems.add( tagp ); + add( tagp, getComponentCount() - 2 ); + } + n++; + } + revalidate(); + repaint(); + } + + @Override + public boolean requestFocusInWindow() + { + return textField.requestFocusInWindow(); + } + + /* + * INNER CLASSES + */ + + private class SeparatorKeyItem extends KeyItem + { + + private static final long serialVersionUID = 1L; + + public SeparatorKeyItem() + { + super( "or", true ); + } + + @Override + protected void updateTxtLook() + { + if ( txt != null ) + { + txt.setOpaque( false ); + Font font = UIManager.getFont( "Label.font" ); + font = font.deriveFont( font.getSize2D() - 4f ); + txt.setFont( font ); + } + } + } + + private class KeyItem extends JPanel + { + private static final long serialVersionUID = 1L; + + private final boolean valid; + + protected final JLabel txt; + + public KeyItem( final String tag, final boolean valid ) + { + this.valid = valid; + final String str = TRIGGER_SYMBOLS.containsKey( tag ) ? ( " " + TRIGGER_SYMBOLS.get( tag ) + " " ) : ( " " + tag + " " ); + txt = new JLabel( str ); + txt.setOpaque( true ); + updateTxtLook(); + + setLayout( new BoxLayout( this, BoxLayout.LINE_AXIS ) ); + add( Box.createHorizontalStrut( 1 ) ); + add( txt ); + add( Box.createHorizontalStrut( 1 ) ); + setOpaque( false ); + } + + @Override + public void updateUI() + { + super.updateUI(); + updateTxtLook(); + } + + protected void updateTxtLook() + { + if ( txt != null ) + { + final Color tfg = UIManager.getColor( "TextField.foreground" ); + final Color tbg = UIManager.getColor( "TextField.background" ); + final Color bg = valid ? mix( tbg, tfg, 0.95 ) : mix( tbg, Color.red, 0.5 ); + final Color borderColor = mix( bg, tfg, 0.8 ); + txt.setBackground( bg ); + txt.setBorder( new RoundBorder( borderColor, MultiInputTriggerPanelEditor.this, 1 ) ); + + Font font = UIManager.getFont( "Label.font" ); + font = font.deriveFont( font.getSize2D() - 2f ); + txt.setFont( font ); + } + } + } + + /** Contains the tags in the order we want them to appear in the panel. */ + private static final List< String > INPUT_TRIGGER_SYNTAX_TAGS = new ArrayList<>(); + + /** + * Contains the tags sorted so that they can be searched by the autocomplete + * process. + */ + private static final List< String > INPUT_TRIGGER_SYNTAX_TAGS_SORTED = new ArrayList<>(); + + /** Small-caps version of INPUT_TRIGGER_SYNTAX_TAGS_SORTED. */ + private static final List< String > INPUT_TRIGGER_SYNTAX_TAGS_SMALL_CAPS; + + /** Visual replacement for some tags. */ + private static final Map< String, String > TRIGGER_SYMBOLS = new HashMap<>(); + + private static final Map< String, String > INPUT_TRIGGER_SYNTAX_TAG_REMAP = new HashMap<>(); + + static + { + INPUT_TRIGGER_SYNTAX_TAGS.addAll( + Arrays.asList( + "all", + "ctrl", + "alt", + "altGraph", + "shift", + "meta", + "command", + "cmd", + "win", + "ENTER", + "BACK_SPACE", + "TAB", + "CANCEL", + "CLEAR", + "COMPOSE", + "PAUSE", + "CAPS_LOCK", + "ESCAPE", + "SPACE", + "PAGE_UP", + "PAGE_DOWN", + "END", + "HOME", + "BEGIN", + "COMMA", + "PERIOD", + "SLASH", + "SEMICOLON", + "EQUALS", + "OPEN_BRACKET", + "BACK_SLASH", + "CLOSE_BRACKET", + "LEFT", + "UP", + "RIGHT", + "DOWN", + "NUMPAD0", + "NUMPAD1", + "NUMPAD2", + "NUMPAD3", + "NUMPAD4", + "NUMPAD5", + "NUMPAD6", + "NUMPAD7", + "NUMPAD8", + "NUMPAD9", + "MULTIPLY", + "ADD", + "SEPARATOR", + "SUBTRACT", + "DECIMAL", + "DIVIDE", + "DELETE", + "NUM_LOCK", + "SCROLL_LOCK", + "double-click", + "button1", + "button2", + "button3", + "scroll", + "|" ) ); + for ( int i = 0; i < 26; i++ ) + INPUT_TRIGGER_SYNTAX_TAGS.add( String.valueOf( ( char ) ( 'A' + i ) ) ); + for ( int i = 0; i < 10; i++ ) + INPUT_TRIGGER_SYNTAX_TAGS.add( "" + i ); + for ( int i = 1; i <= 24; i++ ) + INPUT_TRIGGER_SYNTAX_TAGS.add( "F" + i ); + + INPUT_TRIGGER_SYNTAX_TAGS_SORTED.addAll( INPUT_TRIGGER_SYNTAX_TAGS ); + INPUT_TRIGGER_SYNTAX_TAGS_SORTED.sort( String.CASE_INSENSITIVE_ORDER ); + INPUT_TRIGGER_SYNTAX_TAGS_SMALL_CAPS = new ArrayList<>( INPUT_TRIGGER_SYNTAX_TAGS_SORTED.size() ); + for ( final String tag : INPUT_TRIGGER_SYNTAX_TAGS_SORTED ) + INPUT_TRIGGER_SYNTAX_TAGS_SMALL_CAPS.add( tag.toLowerCase() ); + + INPUT_TRIGGER_SYNTAX_TAG_REMAP.put( "cmd", "meta" ); + INPUT_TRIGGER_SYNTAX_TAG_REMAP.put( "command", "meta" ); + INPUT_TRIGGER_SYNTAX_TAG_REMAP.put( "windows", "win" ); + + TRIGGER_SYMBOLS.put( "ENTER", "\u23CE" ); + TRIGGER_SYMBOLS.put( "BACK_SPACE", "\u232B" ); + TRIGGER_SYMBOLS.put( "DELETE", "\u2326" ); + TRIGGER_SYMBOLS.put( "TAB", "\u21E5" ); + TRIGGER_SYMBOLS.put( "PAUSE", "||" ); + TRIGGER_SYMBOLS.put( "CAPS_LOCK", "\u21EA" ); + TRIGGER_SYMBOLS.put( "PAGE_UP", "\u21DE" ); + TRIGGER_SYMBOLS.put( "PAGE_DOWN", "\u21DF" ); + TRIGGER_SYMBOLS.put( "END", "\u2198" ); + TRIGGER_SYMBOLS.put( "HOME", "\u2196" ); + TRIGGER_SYMBOLS.put( "ESCAPE", "\u238b" ); + TRIGGER_SYMBOLS.put( "LEFT", "\u2190" ); + TRIGGER_SYMBOLS.put( "UP", "\u2191" ); + TRIGGER_SYMBOLS.put( "RIGHT", "\u2192" ); + TRIGGER_SYMBOLS.put( "DOWN", "\u2193" ); + TRIGGER_SYMBOLS.put( "NUMPAD0", "\u24ea" ); + TRIGGER_SYMBOLS.put( "NUMPAD1", "\u2460" ); + TRIGGER_SYMBOLS.put( "NUMPAD2", "\u2461" ); + TRIGGER_SYMBOLS.put( "NUMPAD3", "\u2462" ); + TRIGGER_SYMBOLS.put( "NUMPAD4", "\u2463" ); + TRIGGER_SYMBOLS.put( "NUMPAD5", "\u2464" ); + TRIGGER_SYMBOLS.put( "NUMPAD6", "\u2465" ); + TRIGGER_SYMBOLS.put( "NUMPAD7", "\u2466" ); + TRIGGER_SYMBOLS.put( "NUMPAD8", "\u2467" ); + TRIGGER_SYMBOLS.put( "NUMPAD9", "\u2468" ); + TRIGGER_SYMBOLS.put( "MULTIPLY", "\u00d7" ); + TRIGGER_SYMBOLS.put( "DIVIDE", "\u00f7" ); + TRIGGER_SYMBOLS.put( "ADD", "+" ); + TRIGGER_SYMBOLS.put( "SUBTRACT", "-" ); + TRIGGER_SYMBOLS.put( "COMMA", "," ); + TRIGGER_SYMBOLS.put( "PERIOD", "." ); + TRIGGER_SYMBOLS.put( "SLASH", "/" ); + TRIGGER_SYMBOLS.put( "SEMICOLON", ";" ); + TRIGGER_SYMBOLS.put( "EQUALS", "=" ); + TRIGGER_SYMBOLS.put( "OPEN_BRACKET", "[" ); + TRIGGER_SYMBOLS.put( "BACK_SLASH", "\\" ); + TRIGGER_SYMBOLS.put( "CLOSE_BRACKET", "]" ); + TRIGGER_SYMBOLS.put( "ctrl", "\u2303" ); + TRIGGER_SYMBOLS.put( "alt", "\u2387" ); + TRIGGER_SYMBOLS.put( "shift", "\u21e7" ); + TRIGGER_SYMBOLS.put( "meta", isMac() ? "\u2318" : "\u25c6" ); + TRIGGER_SYMBOLS.put( "win", "\u2756" ); + // Vertical bar is special + TRIGGER_SYMBOLS.put( "|", " | " ); + } + + /** + * Sort tokens in a visually pleasing way. Makes sure we do not mess with + * the '|' syntax. + */ + private static final void sortTokens( final String[] tokens ) + { + int vbarIndex = -1; + for ( int i = 0; i < tokens.length; i++ ) + { + if ( tokens[ i ].equals( "|" ) ) + { + vbarIndex = i; + break; + } + } + if ( vbarIndex >= 0 ) + { + Arrays.sort( tokens, 0, vbarIndex, Comparator.comparingInt( INPUT_TRIGGER_SYNTAX_TAGS::indexOf ) ); + Arrays.sort( tokens, vbarIndex + 1, tokens.length, Comparator.comparingInt( INPUT_TRIGGER_SYNTAX_TAGS::indexOf ) ); + } + else + Arrays.sort( tokens, Comparator.comparingInt( INPUT_TRIGGER_SYNTAX_TAGS::indexOf ) ); + } + + private static boolean isMac() + { + final String OS = System.getProperty( "os.name", "generic" ).toLowerCase( Locale.ENGLISH ); + return ( OS.indexOf( "mac" ) >= 0 ) || ( OS.indexOf( "darwin" ) >= 0 ); + } + + /** + * Mix colors {@code c1} and {@code c2} by ratios {@code c1Weight} and + * {@code (1-c1Weight)}, respectively. + */ + static Color mix( final Color c1, final Color c2, final double c1Weight ) + { + final double c2Weight = 1.0 - c1Weight; + return new Color( + ( int ) ( c1.getRed() * c1Weight + c2.getRed() * c2Weight ), + ( int ) ( c1.getGreen() * c1Weight + c2.getGreen() * c2Weight ), + ( int ) ( c1.getBlue() * c1Weight + c2.getBlue() * c2Weight ) ); + } +}