diff --git a/.github/build.sh b/.github/build.sh new file mode 100755 index 0000000..7da4262 --- /dev/null +++ b/.github/build.sh @@ -0,0 +1,3 @@ +#!/bin/sh +curl -fsLO https://raw.githubusercontent.com/scijava/scijava-scripts/master/ci-build.sh +sh ci-build.sh diff --git a/.github/setup.sh b/.github/setup.sh new file mode 100755 index 0000000..f359bbe --- /dev/null +++ b/.github/setup.sh @@ -0,0 +1,3 @@ +#!/bin/sh +curl -fsLO https://raw.githubusercontent.com/scijava/scijava-scripts/master/ci-setup-github-actions.sh +sh ci-setup-github-actions.sh diff --git a/.github/workflows/build-main.yml b/.github/workflows/build-main.yml new file mode 100644 index 0000000..5ef5692 --- /dev/null +++ b/.github/workflows/build-main.yml @@ -0,0 +1,32 @@ +name: build + +on: + push: + branches: + - master + tags: + - "*-[0-9]+.*" + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Java + uses: actions/setup-java@v3 + with: + java-version: '8' + distribution: 'zulu' + cache: 'maven' + - name: Set up CI environment + run: .github/setup.sh + - name: Execute the build + run: .github/build.sh + env: + GPG_KEY_NAME: ${{ secrets.GPG_KEY_NAME }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + MAVEN_USER: ${{ secrets.MAVEN_USER }} + MAVEN_PASS: ${{ secrets.MAVEN_PASS }} + OSSRH_PASS: ${{ secrets.OSSRH_PASS }} + SIGNING_ASC: ${{ secrets.SIGNING_ASC }} diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml new file mode 100644 index 0000000..925b576 --- /dev/null +++ b/.github/workflows/build-pr.yml @@ -0,0 +1,23 @@ +name: build PR + +on: + pull_request: + branches: + - master + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Java + uses: actions/setup-java@v3 + with: + java-version: '8' + distribution: 'zulu' + cache: 'maven' + - name: Set up CI environment + run: .github/setup.sh + - name: Execute the build + run: .github/build.sh diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..84f23ce --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,25 @@ +Copyright (c) 2015 - 2023, Max Planck Institute of Molecular Cell Biology +and Genetics. +All rights reserved. + +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 HOLDER 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. diff --git a/README.md b/README.md index 5b148c1..d7de467 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ +[![](https://github.com/scijava/ui-behaviour/actions/workflows/build-main.yml/badge.svg)](https://github.com/scijava/ui-behaviour/actions/workflows/build-main.yml) + # configurable-keys Simplify making AWT mouse-handlers configurable. Works along the lines of the `InputMap` / `ActionMap` mechanism. + +The syntax for defining key and mouse "triggers" is described in the [InputTrigger-syntax wiki page](https://github.com/scijava/ui-behaviour/wiki/InputTrigger-syntax) diff --git a/pom.xml b/pom.xml index bf275dd..5d9c325 100644 --- a/pom.xml +++ b/pom.xml @@ -1,94 +1,139 @@ - + 4.0.0 - net.imagej - pom-imagej - 14.4.1 + org.scijava + pom-scijava + 36.0.0 + - configurable-keys - 1.0.0-SNAPSHOT - http://github.com/bigdataviewer/configurable-keys - 2015 - - Configurable Keys - configurable key and mouse event handling - - - 1.8 - + ui-behaviour + 2.0.9-SNAPSHOT - - - net.sf.trove4j - trove4j - 3.0.3 - - - com.google.code.gson - gson - - - org.yaml - snakeyaml - 1.8 - - + UI Behaviour + Configurable key and mouse event handling + http://github.com/scijava/ui-behaviour + 2015 + + SciJava + https://scijava.org/ + + + + Simplified BSD License + https://opensource.org/licenses/BSD-2-Clause + repo + + tpietzsch Tobias Pietzsch - pietzsch@mpi-cbg.de - - MPI-CBG - http://www.mpi-cbg.de/ + https://imagej.net/people/tpietzsch - architect + founder + lead developer + debugger + reviewer + support + maintainer - +1 tinevez Jean-Yves Tinevez - jean-yves.tinevez@pasteur.fr - http://www.pasteur.fr/ip/easysite/pasteur/fr/recherche/plates-formes-technologiques/imagopole/plates-formes-imagopole/la-plate-forme-d-imagerie-dynamique/equipe-pfid/equipe-pfid-jyt - Institut Pasteur - http://www.pasteur.fr/ + https://imagej.net/people/tinevez - architect developer + debugger + reviewer + + + + ctrueden + Curtis Rueden + https://imagej.net/people/ctrueden + + maintainer - +1 + + + Ulrik Günther + https://imagej.net/people/skalarproduktraum + skalarproduktraum + + - - - imagej.public - http://maven.imagej.net/content/groups/public - - + + + SciJava + https://groups.google.com/group/scijava + https://groups.google.com/group/scijava + scijava@googlegroups.com + https://groups.google.com/group/scijava + + - scm:git:git://github.com/bigdataviewer/configurable-keys - scm:git:git@github.com:bigdataviewer/configurable-keys + scm:git:https://github.com/scijava/ui-behaviour + scm:git:git@github.com:scijava/ui-behaviour HEAD - https://github.com/bigdataviewer/configurable-keys + https://github.com/scijava/ui-behaviour + + GitHub Issues + https://github.com/scijava/ui-behaviour/issues + - Jenkins - http://jenkins.imagej.net/job/Configurable-Keys/ + GitHub Actions + https://github.com/scijava/ui-behaviour/actions - - - BSD 2-Clause License - http://opensource.org/licenses/BSD-2-Clause - repo - - + + org.scijava.ui.behaviour + bsd_2 + Max Planck Institute of Molecular Cell Biology +and Genetics. + + + sign,deploy-to-scijava + + 2.0 + + + + + scijava.public + https://maven.scijava.org/content/groups/public + + + + + + org.scijava + scijava-listeners + + + org.scijava + scijava-common + + + net.sf.trove4j + trove4j + + + com.google.code.gson + gson + + + org.yaml + snakeyaml + + diff --git a/src/main/java/bdv/behaviour/Behaviour.java b/src/main/java/bdv/behaviour/Behaviour.java deleted file mode 100644 index db410df..0000000 --- a/src/main/java/bdv/behaviour/Behaviour.java +++ /dev/null @@ -1,6 +0,0 @@ -package bdv.behaviour; - -public interface Behaviour -{ - -} diff --git a/src/main/java/bdv/behaviour/ClickBehaviour.java b/src/main/java/bdv/behaviour/ClickBehaviour.java deleted file mode 100644 index 7c40b68..0000000 --- a/src/main/java/bdv/behaviour/ClickBehaviour.java +++ /dev/null @@ -1,15 +0,0 @@ -package bdv.behaviour; - -public interface ClickBehaviour extends Behaviour -{ - /** - * A click occuered at the specified location, where click can mean a - * regular mouse click or a typed key. - * - * @param x - * mouse x. - * @param y - * mouse y. - */ - public void click( int x, int y ); -} diff --git a/src/main/java/bdv/behaviour/DragBehaviour.java b/src/main/java/bdv/behaviour/DragBehaviour.java deleted file mode 100644 index c436f75..0000000 --- a/src/main/java/bdv/behaviour/DragBehaviour.java +++ /dev/null @@ -1,49 +0,0 @@ -package bdv.behaviour; - -import java.awt.event.MouseListener; - -public interface DragBehaviour extends Behaviour -{ - /** - * Possibly start the behaviour. This can mean for example that - * {@link MouseListener#mousePressed(java.awt.event.MouseEvent)} event was - * reveiced. Whether this is actually a drag, can only be determined if the - * {@link #drag(int, int)} is called subsequently. - * - * @param x - * mouse x. - * @param y - * mouse y. - */ - public void init( int x, int y ); - /* - * TODO consider changing return type to boolean to indicate whether this - * behaviour "accepts" the drag after checking some preconditions. If it - * doesn't, that means it wont we notified about drag()s. - */ - - /** - * Mouse was dragged. Only called between {@link #init(int, int)} and - * {@link #end(int, int)}. - * - * @param x - * mouse x. - * @param y - * mouse y. - */ - public void drag( int x, int y ); - - /** - * Possibly end the behaviour. This can mean for example that - * {@link MouseListener#mouseReleased(java.awt.event.MouseEvent)} event was - * reveiced. Whether this was actually a drag, can only be determined if the - * {@link #drag(int, int)} was called since the last {@link #init(int, int)} - * . - * - * @param x - * mouse x. - * @param y - * mouse y. - */ - public void end( int x, int y ); -} diff --git a/src/main/java/bdv/behaviour/InputTrigger.java b/src/main/java/bdv/behaviour/InputTrigger.java deleted file mode 100644 index 3eea37d..0000000 --- a/src/main/java/bdv/behaviour/InputTrigger.java +++ /dev/null @@ -1,266 +0,0 @@ -package bdv.behaviour; - -import java.awt.AWTKeyStroke; -import java.awt.event.InputEvent; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; -import java.util.StringTokenizer; - -import javax.swing.KeyStroke; - -import gnu.trove.TIntCollection; -import gnu.trove.iterator.TIntIterator; -import gnu.trove.set.TIntSet; -import gnu.trove.set.hash.TIntHashSet; - -/** - * A combination of keys, mouse buttons, and/or mouse scrolling that can trigger a {@link Behaviour}. - * - *

- * Parsing String in {@link #getFromString(String)} borrows heavily from {@link AWTKeyStroke}. - * - * @author Tobias Pietzsch <tobias.pietzsch@gmail.com> - */ -public class InputTrigger -{ - static final int DOUBLE_CLICK_MASK = 1 << 20; - - static final int SCROLL_MASK = 1 << 21; - - static final int WIN_DOWN_MASK = 1 << 22; - - /** - * Word to use to specify a double-click modifier. - */ - private static final String DOUBLE_CLICK_TEXT = "double-click"; - - /** - * Word to use to specify scrolling. - */ - private static final String SCROLL_TEXT = "scroll"; - - /** - * Word used to specify the windows key. - */ - private static final String WINDOWS_TEXT= "win"; - - private final int mask; - - private final TIntSet pressedKeys; - - private final KeyStroke keyStroke; - - private final int hashcode; - - public static InputTrigger getFromString( final String s ) throws IllegalArgumentException - { - if ( s == null || s.length() == 0 ) - return null; - - final StringTokenizer st = new StringTokenizer(s, " "); - final Map modifierKeywords = getModifierKeywords(); - - int mask = 0; - final TIntSet pressedKeys = new TIntHashSet(); - - final int count = st.countTokens(); - - for ( int i = 1; i <= count; i++ ) - { - final String token = st.nextToken(); - if ( token.equals( "released" ) ) - {} - else if ( token.equals( "pressed" ) ) - {} - else if ( token.equals( "typed" ) ) - {} - else - { - final Integer tokenMask = modifierKeywords.get( token ); - if ( tokenMask != null ) - { - mask |= tokenMask.intValue(); - } - else - { - final KeyStroke ks = KeyStroke.getKeyStroke( token ); - if ( ks == null || ks.getKeyCode() == 0 ) - throw new IllegalArgumentException( "InputTrigger String \"" + s + "\" is formatted incorrectly" ); - pressedKeys.add( ks.getKeyCode() ); - } - } - } - - KeyStroke keyStroke = null; - if ( ( mask & ( DOUBLE_CLICK_MASK | InputEvent.BUTTON1_DOWN_MASK | InputEvent.BUTTON2_DOWN_MASK | InputEvent.BUTTON3_DOWN_MASK ) ) == 0 ) - { - // no mouse keys, no double-click -- pure keystroke - keyStroke = KeyStroke.getKeyStroke( s ); - } - - return getCached( new InputTrigger( mask, pressedKeys, keyStroke ) ); - } - - private InputTrigger( final int mask, final TIntSet pressedKeys, final KeyStroke keyStroke ) - { - this.mask = mask; - this.pressedKeys = pressedKeys; - this.keyStroke = keyStroke; - - int value = 17; - value = 31 * value + mask; - value = 31 * value + pressedKeys.hashCode(); - hashcode = value; - } - - public int getMask() - { - return mask; - } - - public TIntCollection getPressedKeys() - { - return pressedKeys; - } - - public boolean isKeyTriggered() - { - return ( mask & ( InputEvent.BUTTON1_DOWN_MASK | InputEvent.BUTTON2_DOWN_MASK | InputEvent.BUTTON3_DOWN_MASK | SCROLL_MASK ) ) == 0; - } - - public boolean isKeyStroke() - { - return keyStroke != null; - } - - public KeyStroke getKeyStroke() - { - return keyStroke; - } - - public boolean matches( final int mask, final TIntSet keys ) - { - if ( this.mask != mask ) - return false; - else - return keys.equals( pressedKeys ); - } - - @Override - public int hashCode() - { - return hashcode; - } - - @Override - public boolean equals( final Object obj ) - { - if ( obj == this ) - return true; - - if ( ! InputTrigger.class.isInstance( obj ) ) - return false; - - final InputTrigger o = ( InputTrigger ) obj; - return mask == o.mask && pressedKeys.equals( o.pressedKeys ); - } - - @Override - public String toString() - { - final StringBuilder buf = new StringBuilder(); - - addModifierText( InputEvent.SHIFT_DOWN_MASK, "shift", buf ); - addModifierText( InputEvent.CTRL_DOWN_MASK, "ctrl", buf ); - addModifierText( InputEvent.META_DOWN_MASK, "meta", buf ); - addModifierText( InputEvent.ALT_DOWN_MASK, "alt", buf ); - addModifierText( InputEvent.ALT_GRAPH_DOWN_MASK, "altGraph", buf ); - addModifierText( InputEvent.BUTTON1_DOWN_MASK, "button1", buf ); - addModifierText( InputEvent.BUTTON2_DOWN_MASK, "button2", buf ); - addModifierText( InputEvent.BUTTON3_DOWN_MASK, "button3", buf ); - addModifierText( DOUBLE_CLICK_MASK, DOUBLE_CLICK_TEXT, buf ); - addModifierText( SCROLL_MASK, SCROLL_TEXT, buf ); - addModifierText( WIN_DOWN_MASK, WINDOWS_TEXT, buf ); - - final TIntIterator iter = pressedKeys.iterator(); - while ( iter.hasNext() ) - { - final int key = iter.next(); - if ( buf.length() > 0 ) - buf.append( " " ); - final String vkName = KeyStroke.getKeyStroke( key, 0 ).toString(); - buf.append( vkName.replace( "pressed ", "" ) ); - } - - return buf.toString(); - } - - private void addModifierText( final int flag, final String name, final StringBuilder buf ) - { - if ( ( mask & flag ) != 0 ) - { - if ( buf.length() > 0 ) - buf.append( " " ); - buf.append( name ); - } - } - - /* - * Caching - */ - - private static Map< String, Integer > modifierKeywords; - - private synchronized static Map getModifierKeywords() - { - if ( modifierKeywords == null ) - { - final Map< String, Integer > uninitializedMap = new HashMap<>( 8, 1.0f ); - uninitializedMap.put( "shift", - Integer.valueOf( InputEvent.SHIFT_DOWN_MASK ) ); - uninitializedMap.put( "control", - Integer.valueOf( InputEvent.CTRL_DOWN_MASK ) ); - uninitializedMap.put( "ctrl", - Integer.valueOf( InputEvent.CTRL_DOWN_MASK ) ); - uninitializedMap.put( "meta", - Integer.valueOf( InputEvent.META_DOWN_MASK ) ); - uninitializedMap.put( "alt", - Integer.valueOf( InputEvent.ALT_DOWN_MASK ) ); - uninitializedMap.put( "altGraph", - Integer.valueOf( InputEvent.ALT_GRAPH_DOWN_MASK ) ); - uninitializedMap.put( "button1", - Integer.valueOf( InputEvent.BUTTON1_DOWN_MASK ) ); - uninitializedMap.put( "button2", - Integer.valueOf( InputEvent.BUTTON2_DOWN_MASK ) ); - uninitializedMap.put( "button3", - Integer.valueOf( InputEvent.BUTTON3_DOWN_MASK ) ); - uninitializedMap.put( DOUBLE_CLICK_TEXT, - Integer.valueOf( DOUBLE_CLICK_MASK ) ); - uninitializedMap.put( SCROLL_TEXT, - Integer.valueOf( SCROLL_MASK ) ); - uninitializedMap.put( WINDOWS_TEXT, - Integer.valueOf( WIN_DOWN_MASK ) ); - modifierKeywords = - Collections.synchronizedMap( uninitializedMap ); - } - return modifierKeywords; - } - - private static Map< InputTrigger, InputTrigger > cache; - - private synchronized static InputTrigger getCached( final InputTrigger buttonsAndKeys ) - { - if ( cache == null ) - cache = new HashMap<>(); - - final InputTrigger b = cache.get( buttonsAndKeys ); - if ( b == null ) - { - cache.put( buttonsAndKeys, buttonsAndKeys ); - return buttonsAndKeys; - } - else - return b; - } -} diff --git a/src/main/java/bdv/behaviour/InputTriggerAdder.java b/src/main/java/bdv/behaviour/InputTriggerAdder.java deleted file mode 100644 index 792f54e..0000000 --- a/src/main/java/bdv/behaviour/InputTriggerAdder.java +++ /dev/null @@ -1,15 +0,0 @@ -package bdv.behaviour; - -public interface InputTriggerAdder -{ - public interface Factory - { - public InputTriggerAdder inputTriggerAdder( InputTriggerMap map, final String ... contexts ); - } - - public void put( final String behaviourName, final InputTrigger... defaultTriggers ); - - public void put( final String behaviourName, final String... defaultTriggers ); - - public void put( final String behaviourName ); -} diff --git a/src/main/java/bdv/behaviour/KeyStrokeAdder.java b/src/main/java/bdv/behaviour/KeyStrokeAdder.java deleted file mode 100644 index 5d3b39f..0000000 --- a/src/main/java/bdv/behaviour/KeyStrokeAdder.java +++ /dev/null @@ -1,18 +0,0 @@ -package bdv.behaviour; - -import javax.swing.InputMap; -import javax.swing.KeyStroke; - -public interface KeyStrokeAdder -{ - public interface Factory - { - public KeyStrokeAdder keyStrokeAdder( InputMap map, final String ... contexts ); - } - - public void put( final String actionName, final KeyStroke... defaultKeyStrokes ); - - public void put( final String actionName, final String... defaultKeyStrokes ); - - public void put( final String actionName ); -} diff --git a/src/main/java/bdv/behaviour/ScrollBehaviour.java b/src/main/java/bdv/behaviour/ScrollBehaviour.java deleted file mode 100644 index eed7973..0000000 --- a/src/main/java/bdv/behaviour/ScrollBehaviour.java +++ /dev/null @@ -1,22 +0,0 @@ -package bdv.behaviour; - -public interface ScrollBehaviour extends Behaviour -{ - /** - * The mouse-wheel was scrolled by the specified amount horizontally or - * vertically, while the mouse pointer was at the specified location. - * - * @param wheelRotation - * the number of "clicks" the mouse wheel was rotated, may be - * fractional. negative for scrolling up/right and positive for - * scrolling down/left. - * @param isHorizontal - * {@code true} if scrolling horizontally, {@code false} if - * scrolling vertically. - * @param x - * mouse x. - * @param y - * mouse y. - */ - public void scroll( double wheelRotation, boolean isHorizontal, int x, int y ); -} diff --git a/src/main/java/bdv/behaviour/io/InputTriggerConfig.java b/src/main/java/bdv/behaviour/io/InputTriggerConfig.java deleted file mode 100644 index ff75056..0000000 --- a/src/main/java/bdv/behaviour/io/InputTriggerConfig.java +++ /dev/null @@ -1,354 +0,0 @@ -package bdv.behaviour.io; - -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map.Entry; -import java.util.Set; - -import javax.swing.InputMap; -import javax.swing.KeyStroke; - -import bdv.behaviour.InputTrigger; -import bdv.behaviour.InputTriggerAdder; -import bdv.behaviour.InputTriggerMap; -import bdv.behaviour.KeyStrokeAdder; - -public class InputTriggerConfig implements InputTriggerAdder.Factory, KeyStrokeAdder.Factory -{ - final HashMap< String, Set< Input > > actionToInputsMap; - - public InputTriggerConfig() - { - actionToInputsMap = new HashMap<>(); - } - - public InputTriggerConfig( final Collection< InputTriggerDescription > keyMappings ) throws IllegalArgumentException - { - actionToInputsMap = new HashMap<>(); - - if ( keyMappings == null ) - return; - - for ( final InputTriggerDescription mapping : keyMappings ) - { - final String behaviour = mapping.getAction(); - final HashSet< String > contexts = new HashSet< String >(); - contexts.addAll( Arrays.asList( mapping.getContexts() ) ); - final String[] triggers = mapping.getTriggers(); - for ( final String triggerStr : triggers ) - { - final InputTrigger trigger = InputTrigger.getFromString( triggerStr ); - final Input input = new Input( trigger, behaviour, contexts ); - - Set< Input > inputs = actionToInputsMap.get( input.behaviour ); - if ( inputs == null ) - { - inputs = new HashSet< >(); - actionToInputsMap.put( input.behaviour, inputs ); - } - inputs.add( input ); - } - } - } - - @Override - public InputTriggerAdder inputTriggerAdder( final InputTriggerMap map, final String ... contexts ) - { - return new InputTriggerAdderImp( map, this, contexts ); - } - - - @Override - public KeyStrokeAdder keyStrokeAdder( final InputMap map, final String ... contexts ) - { - return new KeyStrokeAdderImp( map, this, contexts ); - } - - public static class InputTriggerAdderImp implements InputTriggerAdder - { - private final InputTriggerMap map; - - private final InputTriggerConfig config; - - private final Set< String > contexts; - - public InputTriggerAdderImp( - final InputTriggerMap map, - final InputTriggerConfig config, - final Set< String > contexts ) - { - this.map = map; - this.config = config; - this.contexts = contexts; - } - - public InputTriggerAdderImp( - final InputTriggerMap map, - final InputTriggerConfig config, - final String ... contexts ) - { - this.map = map; - this.config = config; - this.contexts = new HashSet<>(); - this.contexts.addAll( Arrays.asList( contexts ) ); - } - - @Override - public void put( final String behaviourName, final InputTrigger ... defaultTriggers ) - { - final Set< InputTrigger > triggers = config.getInputs( behaviourName, contexts ); - if ( !triggers.isEmpty() ) - { - for ( final InputTrigger trigger : triggers ) - map.put( trigger, behaviourName ); - } - else if ( defaultTriggers.length > 0 ) - { - for ( final InputTrigger trigger : defaultTriggers ) - map.put( trigger, behaviourName ); - } - else - { - System.err.println( "Could not assign InputTrigger for \"" + behaviourName + "\". Nothing defined in InputTriggerConfig, and no default given." ); - } - } - - @Override - public void put( final String behaviourName, final String ... defaultTriggers ) - { - final InputTrigger[] triggers = new InputTrigger[ defaultTriggers.length ]; - int i = 0; - for ( final String s : defaultTriggers ) - triggers[ i++ ] = InputTrigger.getFromString( s ); - put( behaviourName, triggers ); - } - - @Override - public void put( final String behaviourName ) - { - put( behaviourName, new InputTrigger[ 0 ] ); - } - } - - public static class KeyStrokeAdderImp implements KeyStrokeAdder - { - private final InputMap map; - - private final InputTriggerConfig config; - - private final Set< String > contexts; - - public KeyStrokeAdderImp( - final InputMap map, - final InputTriggerConfig config, - final Set< String > contexts ) - { - this.map = map; - this.config = config; - this.contexts = contexts; - } - - public KeyStrokeAdderImp( - final InputMap map, - final InputTriggerConfig config, - final String ... contexts ) - { - this.map = map; - this.config = config; - this.contexts = new HashSet< String >(); - this.contexts.addAll( Arrays.asList( contexts ) ); - } - - @Override - public void put( final String actionName, final KeyStroke... defaultKeyStrokes ) - { - final Set< InputTrigger > triggers = config.getInputs( actionName, contexts ); - boolean configKeyAdded = false; - for ( final InputTrigger trigger : triggers ) - { - if ( trigger.isKeyStroke() ) - { - map.put( trigger.getKeyStroke(), actionName ); - configKeyAdded = true; - } - } - if ( configKeyAdded ) - return; - - if ( defaultKeyStrokes.length > 0 ) - { - for ( final KeyStroke keyStroke : defaultKeyStrokes ) - map.put( keyStroke, actionName ); - } - else - { - System.err.println( "Could not assign KeyStroke for \"" + actionName + "\". Nothing defined in InputTriggerConfig, and no default given." ); - } - } - - @Override - public void put( final String actionName, final String... defaultKeyStrokes ) - { - final KeyStroke[] keyStrokes = new KeyStroke[ defaultKeyStrokes.length ]; - int i = 0; - for ( final String s : defaultKeyStrokes ) - keyStrokes[ i++ ] = KeyStroke.getKeyStroke( s ); - put( actionName, keyStrokes ); - } - - @Override - public void put( final String actionName ) - { - put( actionName, new KeyStroke[ 0 ] ); - } - } - - /* - * PRIVATE... - */ - - static class Input - { - final InputTrigger trigger; - - final String behaviour; - - final Set< String > contexts; - - Input( - final InputTrigger trigger, - final String behaviour, - final Set< String > contexts ) - { - this.trigger = trigger; - this.behaviour = behaviour; - this.contexts = new HashSet<>( contexts ); - } - - @Override - public int hashCode() - { - int value = 17; - value = 31 * value + trigger.hashCode(); - value = 31 * value + behaviour.hashCode(); - value = 31 * value + contexts.hashCode(); - return value; - } - - @Override - public boolean equals( final Object obj ) - { - if ( obj == null && !( obj instanceof Input ) ) - return false; - final Input i = ( Input ) obj; - return i.trigger.equals( trigger ) && i.behaviour.equals( behaviour ) && i.contexts.equals( contexts ); - } - - InputTriggerDescription getDescription() - { - return new InputTriggerDescription( new String[] { trigger.toString() }, behaviour, contexts.toArray( new String[ 0 ] ) ); - } - } - - Set< InputTrigger > getInputs( final String behaviourName, final Set< String > contexts ) - { - final Set< Input > inputs = actionToInputsMap.get( behaviourName ); - final Set< InputTrigger > triggers = new HashSet<>(); - if ( inputs != null ) - { - for ( final Input input : inputs ) - if ( ! Collections.disjoint( contexts, input.contexts ) ) - triggers.add( input.trigger ); - } - return triggers; - } - - /* - * creating InputTriggerConfig from InputTriggerMaps and InputMaps - */ - - void addMap( final InputTriggerMap map, final String context ) - { - for ( final Entry< InputTrigger, Set< String > > entry : map.getAllBindings().entrySet() ) - { - final InputTrigger trigger = entry.getKey(); - final Set< String > behaviours = entry.getValue(); - - for ( final String behaviourName : behaviours ) - { - Set< Input > inputs = actionToInputsMap.get( behaviourName ); - if ( inputs == null ) - { - inputs = new HashSet<>(); - actionToInputsMap.put( behaviourName, inputs ); - } - - boolean added = false; - for ( final Input input : inputs ) - { - if ( input.trigger.equals( trigger ) ) - { - /* - * the trigger -> behaviour binding already exists. - * just add the new context - */ - input.contexts.add( context ); - added = true; - break; - } - } - - if ( !added ) - { - inputs.add( new Input( - trigger, - behaviourName, - Collections.singleton( context ) ) ); - } - } - } - } - - void addMap( final InputMap map, final String context ) - { - final KeyStroke[] keys = map.allKeys(); - for ( final KeyStroke key : keys ) - { - final InputTrigger trigger = InputTrigger.getFromString( key.toString() ); - final String behaviourName = map.get( key ).toString(); - - Set< Input > inputs = actionToInputsMap.get( behaviourName ); - if ( inputs == null ) - { - inputs = new HashSet<>(); - actionToInputsMap.put( behaviourName, inputs ); - } - - boolean added = false; - for ( final Input input : inputs ) - { - if ( input.trigger.equals( trigger ) ) - { - /* - * the trigger -> behavioiur binding already exists. - * just add the new context - */ - input.contexts.add( context ); - added = true; - break; - } - } - - if ( !added ) - { - inputs.add( new Input( - trigger, - behaviourName, - Collections.singleton( context ) ) ); - } - } - } -} diff --git a/src/main/java/bdv/behaviour/io/InputTriggerDescription.java b/src/main/java/bdv/behaviour/io/InputTriggerDescription.java deleted file mode 100644 index 7fad234..0000000 --- a/src/main/java/bdv/behaviour/io/InputTriggerDescription.java +++ /dev/null @@ -1,95 +0,0 @@ -package bdv.behaviour.io; - -import javax.swing.Action; - -import bdv.behaviour.Behaviour; -import bdv.behaviour.InputTrigger; - -/** - * IO record describing the mapping of one {@link InputTrigger} to an - * {@link Action} or a {@link Behaviour}. - * - * @author Tobias Pietzsch <tobias.pietzsch@gmail.com> - */ -public class InputTriggerDescription -{ - /** - * String representations of the {@link InputTrigger}s. - */ - private String[] triggers; - - /** - * Key of the {@link Action} or {@link Behaviour}. - */ - private String action; - - /** - * A list of contexts in which this mapping is active. - */ - private String[] contexts; - - public InputTriggerDescription( - final String[] triggers, - final String action, - final String... contexts ) - { - this.triggers = triggers; - this.action = action; - setContexts( contexts ); - } - - public InputTriggerDescription() - {} - - @Override - public String toString() - { - String s = "( trigger = ["; - if ( triggers != null ) - for ( int i = 0; i < triggers.length; ++i ) - s += "\"" + triggers[ i ] + "\"" + ( i == triggers.length - 1 ? "" : ", " ); - s += "]\n"; - - s += " action = \"" + action + "\"\n"; - - s += " contexts = ["; - if ( contexts != null ) - for ( int i = 0; i < contexts.length; ++i ) - s += "\"" + contexts[ i ] + "\"" + ( i == contexts.length - 1 ? "" : ", " ); - s += "] )\n"; - return s; - } - - public String[] getTriggers() - { - return triggers; - } - - public String getAction() - { - return action; - } - - public String[] getContexts() - { - return contexts; - } - - public void setTriggers( final String[] triggers ) - { - this.triggers = triggers; - } - - public void setAction( final String action ) - { - this.action = action; - } - - public void setContexts( final String[] contexts ) - { - if ( contexts == null || ( contexts.length == 1 && contexts[ 0 ].isEmpty() ) ) - this.contexts = new String[ 0 ]; - else - this.contexts = contexts; - } -} \ No newline at end of file diff --git a/src/main/java/bdv/behaviour/io/InputTriggerDescriptionsBuilder.java b/src/main/java/bdv/behaviour/io/InputTriggerDescriptionsBuilder.java deleted file mode 100644 index d86fa74..0000000 --- a/src/main/java/bdv/behaviour/io/InputTriggerDescriptionsBuilder.java +++ /dev/null @@ -1,76 +0,0 @@ -package bdv.behaviour.io; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashSet; -import java.util.List; -import java.util.Map.Entry; -import java.util.Set; - -import javax.swing.InputMap; - -import bdv.behaviour.InputTriggerMap; -import bdv.behaviour.io.InputTriggerConfig.Input; - -/** - * Creates {@link InputTriggerDescription}s from existing {@link InputMap}s and {@link InputTriggerMap}s. - * This can be used to dump the current key and mouse configuration of an application to a config file. - * - * @author Tobias Pietzsch <tobias.pietzsch@gmail.com> - */ -public class InputTriggerDescriptionsBuilder -{ - private final InputTriggerConfig config; - - public InputTriggerDescriptionsBuilder() - { - config = new InputTriggerConfig(); - } - - public List< InputTriggerDescription > getDescriptions() - { - final ArrayList< InputTriggerDescription > descs = new ArrayList<>(); - final String[] emptyStringArray = new String[ 0 ]; - - for ( final Entry< String, Set< Input > > entry : config.actionToInputsMap.entrySet() ) - { - final Set< Input > inputs = entry.getValue(); - for ( final Input input : inputs ) - { - boolean found = false; - for ( final InputTriggerDescription desc : descs ) - { - if ( input.behaviour.equals( desc.getAction() ) && - input.contexts.equals( new HashSet<>( Arrays.asList( desc.getContexts() ) ) ) ) - { - final HashSet< String > triggers = new HashSet<>( Arrays.asList( desc.getTriggers() ) ); - triggers.add( input.trigger.toString() ); - desc.setTriggers( triggers.toArray( emptyStringArray ) ); - found = true; - break; - } - } - if ( !found ) - { - final InputTriggerDescription desc = new InputTriggerDescription( - new String[] { input.trigger.toString() }, - input.behaviour, - input.contexts.toArray( emptyStringArray ) ); - descs.add( desc ); - } - } - } - - return descs; - } - - public void addMap( final InputTriggerMap map, final String context ) - { - config.addMap( map, context ); - } - - public void addMap( final InputMap map, final String context ) - { - config.addMap( map, context ); - } -} diff --git a/src/main/java/bdv/behaviour/io/json/JsonConfigIO.java b/src/main/java/bdv/behaviour/io/json/JsonConfigIO.java deleted file mode 100644 index 19e7a4d..0000000 --- a/src/main/java/bdv/behaviour/io/json/JsonConfigIO.java +++ /dev/null @@ -1,47 +0,0 @@ -package bdv.behaviour.io.json; - -import java.io.FileReader; -import java.io.FileWriter; -import java.io.IOException; -import java.io.Reader; -import java.io.Writer; -import java.lang.reflect.Type; -import java.util.List; - -import bdv.behaviour.io.InputTriggerDescription; - -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.reflect.TypeToken; - -public class JsonConfigIO -{ - public static void write( final List< InputTriggerDescription > descriptions, final Writer writer ) - { - final Gson gson = new GsonBuilder().setPrettyPrinting().create(); - gson.toJson( descriptions, writer ); - } - - public static void write( final List< InputTriggerDescription > descriptions, final String fileName ) throws IOException - { - final FileWriter writer = new FileWriter( fileName ); - write( descriptions, writer ); - writer.close(); - } - - public static List< InputTriggerDescription > read( final Reader reader ) - { - final Gson gson = new GsonBuilder().create(); - final Type type = new TypeToken< List< InputTriggerDescription > >(){}.getType(); - final List< InputTriggerDescription > descriptions = gson.fromJson( reader, type ); - return descriptions; - } - - public static List< InputTriggerDescription > read( final String fileName ) throws IOException - { - final FileReader reader = new FileReader( fileName ); - final List< InputTriggerDescription > descriptions = read( reader ); - reader.close(); - return descriptions; - } -} diff --git a/src/main/java/bdv/behaviour/util/AbstractNamedBehaviour.java b/src/main/java/bdv/behaviour/util/AbstractNamedBehaviour.java deleted file mode 100644 index 7d00f83..0000000 --- a/src/main/java/bdv/behaviour/util/AbstractNamedBehaviour.java +++ /dev/null @@ -1,39 +0,0 @@ -package bdv.behaviour.util; - -import bdv.behaviour.Behaviour; -import bdv.behaviour.BehaviourMap; - -public abstract class AbstractNamedBehaviour implements Behaviour -{ - private final String name; - - public AbstractNamedBehaviour( final String name ) - { - this.name = name; - } - - public String name() - { - return name; - } - - public static void put( final BehaviourMap map, final AbstractNamedBehaviour a ) - { - map.put( a.name(), a ); - } - - public static class NamedBehaviourAdder - { - private final BehaviourMap map; - - public NamedBehaviourAdder( final BehaviourMap map ) - { - this.map = map; - } - - public void put( final AbstractNamedBehaviour a ) - { - AbstractNamedBehaviour.put( map, a ); - } - } -} diff --git a/src/main/java/org/scijava/ui/behaviour/AbstractMouseAndKeyHandler.java b/src/main/java/org/scijava/ui/behaviour/AbstractMouseAndKeyHandler.java new file mode 100644 index 0000000..527196f --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/AbstractMouseAndKeyHandler.java @@ -0,0 +1,180 @@ +/*- + * #%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.scijava.ui.behaviour; + +import java.awt.Toolkit; +import java.util.ArrayList; +import java.util.Map; +import java.util.Set; + +public abstract class AbstractMouseAndKeyHandler +{ + protected static final int DOUBLE_CLICK_INTERVAL = getDoubleClickInterval(); + + private static int getDoubleClickInterval() + { + final Object prop = Toolkit.getDefaultToolkit().getDesktopProperty( "awt.multiClickInterval" ); + return prop == null ? 200 : ( Integer ) prop; + } + + private InputTriggerMap inputMap; + + private BehaviourMap behaviourMap; + + private int inputMapExpectedModCount; + + private int behaviourMapExpectedModCount; + + public void setInputMap( final InputTriggerMap inputMap ) + { + this.inputMap = inputMap; + inputMapExpectedModCount = inputMap.modCount() - 1; + } + + public void setBehaviourMap( final BehaviourMap behaviourMap ) + { + this.behaviourMap = behaviourMap; + behaviourMapExpectedModCount = behaviourMap.modCount() - 1; + } + + /* + * Managing internal behaviour lists. + * + * The internal lists only contain entries for Behaviours that can be + * actually triggered with the current InputMap, grouped by Behaviour type, + * such that hopefully lookup from the event handlers is fast, + */ + + protected static class BehaviourEntry< T extends Behaviour > + { + private final InputTrigger buttons; + + private final T behaviour; + + public BehaviourEntry( + final InputTrigger buttons, + final T behaviour ) + { + this.buttons = buttons; + this.behaviour = behaviour; + } + + public InputTrigger buttons() + { + return buttons; + } + + public T behaviour() + { + return behaviour; + } + } + + protected final ArrayList< BehaviourEntry< DragBehaviour > > buttonDrags = new ArrayList<>(); + + protected final ArrayList< BehaviourEntry< DragBehaviour > > keyDrags = new ArrayList<>(); + + protected final ArrayList< BehaviourEntry< ClickBehaviour > > buttonClicks = new ArrayList<>(); + + protected final ArrayList< BehaviourEntry< ClickBehaviour > > keyClicks = new ArrayList<>(); + + protected final ArrayList< BehaviourEntry< ScrollBehaviour > > scrolls = new ArrayList<>(); + + /** + * Make sure that the internal behaviour lists are up to date. For this, we + * keep track the modification count of {@link #inputMap} and + * {@link #behaviourMap}. If expected mod counts are not matched, call + * {@link #updateInternalMaps()} to rebuild the internal behaviour lists. + */ + protected synchronized void update() + { + final int imc = inputMap.modCount(); + final int bmc = behaviourMap.modCount(); + if ( imc != inputMapExpectedModCount || bmc != behaviourMapExpectedModCount ) + { + inputMapExpectedModCount = imc; + behaviourMapExpectedModCount = bmc; + updateInternalMaps(); + } + } + + /** + * Build internal lists buttonDrag, keyDrags, etc from of {@link #inputMap} + * and {@link #behaviourMap}. The internal lists only contain entries for + * behaviours that can be actually triggered with the current InputMap, + * grouped by behaviour type, such that hopefully lookup from the event + * handlers is fast. + */ + private void updateInternalMaps() + { + buttonDrags.clear(); + keyDrags.clear(); + buttonClicks.clear(); + keyClicks.clear(); + scrolls.clear(); + + for ( final Map.Entry< InputTrigger, Set< String > > entry : inputMap.getAllBindings().entrySet() ) + { + final InputTrigger buttons = entry.getKey(); + final Set< String > behaviourKeys = entry.getValue(); + if ( behaviourKeys == null ) + continue; + + for ( final String behaviourKey : behaviourKeys ) + { + final Behaviour behaviour = behaviourMap.get( behaviourKey ); + if ( behaviour == null ) + continue; + + if ( behaviour instanceof DragBehaviour ) + { + final BehaviourEntry< DragBehaviour > dragEntry = new BehaviourEntry<>( buttons, ( DragBehaviour ) behaviour ); + if ( buttons.isKeyTriggered() ) + keyDrags.add( dragEntry ); + else + buttonDrags.add( dragEntry ); + } + if ( behaviour instanceof ClickBehaviour ) + { + final BehaviourEntry< ClickBehaviour > clickEntry = new BehaviourEntry<>( buttons, ( ClickBehaviour ) behaviour ); + if ( buttons.isKeyTriggered() ) + keyClicks.add( clickEntry ); + else + buttonClicks.add( clickEntry ); + } + if ( behaviour instanceof ScrollBehaviour ) + { + final BehaviourEntry< ScrollBehaviour > scrollEntry = new BehaviourEntry<>( buttons, ( ScrollBehaviour ) behaviour ); + scrolls.add( scrollEntry ); + } + } + } + } +} diff --git a/src/main/java/org/scijava/ui/behaviour/Behaviour.java b/src/main/java/org/scijava/ui/behaviour/Behaviour.java new file mode 100644 index 0000000..64fceb2 --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/Behaviour.java @@ -0,0 +1,35 @@ +/*- + * #%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.scijava.ui.behaviour; + +public interface Behaviour +{ + +} diff --git a/src/main/java/bdv/behaviour/BehaviourMap.java b/src/main/java/org/scijava/ui/behaviour/BehaviourMap.java similarity index 64% rename from src/main/java/bdv/behaviour/BehaviourMap.java rename to src/main/java/org/scijava/ui/behaviour/BehaviourMap.java index 369cfd0..73eb005 100644 --- a/src/main/java/bdv/behaviour/BehaviourMap.java +++ b/src/main/java/org/scijava/ui/behaviour/BehaviourMap.java @@ -1,9 +1,40 @@ -package bdv.behaviour; +/*- + * #%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.scijava.ui.behaviour; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; import java.util.Map.Entry; +import java.util.Set; import javax.swing.ActionMap; /** @@ -127,7 +158,12 @@ public synchronized Map< String, Behaviour > getAllBindings() return allBindings; } - protected int modCount() + public synchronized Set< String > keys() + { + return new HashSet<>( behaviours.keySet() ); + } + + public int modCount() { if ( parent != null ) { diff --git a/src/main/java/org/scijava/ui/behaviour/ClickBehaviour.java b/src/main/java/org/scijava/ui/behaviour/ClickBehaviour.java new file mode 100644 index 0000000..f9daab8 --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/ClickBehaviour.java @@ -0,0 +1,44 @@ +/*- + * #%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.scijava.ui.behaviour; + +public interface ClickBehaviour extends Behaviour +{ + /** + * A click occuered at the specified location, where click can mean a + * regular mouse click or a typed key. + * + * @param x + * mouse x. + * @param y + * mouse y. + */ + public void click( int x, int y ); +} diff --git a/src/main/java/org/scijava/ui/behaviour/DragBehaviour.java b/src/main/java/org/scijava/ui/behaviour/DragBehaviour.java new file mode 100644 index 0000000..bffcab4 --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/DragBehaviour.java @@ -0,0 +1,78 @@ +/*- + * #%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.scijava.ui.behaviour; + +import java.awt.event.MouseListener; + +public interface DragBehaviour extends Behaviour +{ + /** + * Possibly start the behaviour. This can mean for example that + * {@link MouseListener#mousePressed(java.awt.event.MouseEvent)} event was + * reveiced. Whether this is actually a drag, can only be determined if the + * {@link #drag(int, int)} is called subsequently. + * + * @param x + * mouse x. + * @param y + * mouse y. + */ + public void init( int x, int y ); + /* + * TODO consider changing return type to boolean to indicate whether this + * behaviour "accepts" the drag after checking some preconditions. If it + * doesn't, that means it wont we notified about drag()s. + */ + + /** + * Mouse was dragged. Only called between {@link #init(int, int)} and + * {@link #end(int, int)}. + * + * @param x + * mouse x. + * @param y + * mouse y. + */ + public void drag( int x, int y ); + + /** + * Possibly end the behaviour. This can mean for example that + * {@link MouseListener#mouseReleased(java.awt.event.MouseEvent)} event was + * reveiced. Whether this was actually a drag, can only be determined if the + * {@link #drag(int, int)} was called since the last {@link #init(int, int)} + * . + * + * @param x + * mouse x. + * @param y + * mouse y. + */ + public void end( int x, int y ); +} diff --git a/src/main/java/org/scijava/ui/behaviour/GlobalKeyEventDispatcher.java b/src/main/java/org/scijava/ui/behaviour/GlobalKeyEventDispatcher.java new file mode 100644 index 0000000..2592779 --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/GlobalKeyEventDispatcher.java @@ -0,0 +1,219 @@ +/*- + * #%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.scijava.ui.behaviour; + +import java.awt.Component; +import java.awt.KeyEventDispatcher; +import java.awt.KeyboardFocusManager; +import java.awt.event.KeyEvent; +import java.beans.PropertyChangeEvent; +import java.beans.PropertyChangeListener; + +import gnu.trove.set.TIntSet; +import gnu.trove.set.hash.TIntHashSet; + +/** + * Global {@link KeyEventDispatcher} hook that allows to share state of modifier + * keys between {@link MouseAndKeyHandler} in different windows of the + * application. + * + * @author Tobias Pietzsch <tobias.pietzsch@gmail.com> + */ +public class GlobalKeyEventDispatcher implements KeyEventDispatcher +{ + public static GlobalKeyEventDispatcher getInstance() + { + install(); + return instance; + } + + /** + * Which keys are currently pressed. This does not include modifier keys + * Control, Shift, Alt, AltGr, Meta. + */ + private final TIntSet pressedKeys = new TIntHashSet( 5, 0.5f, -1 ); + + /** + * Whether the SHIFT key is currently pressed. We need this, because for + * mouse-wheel AWT uses the SHIFT_DOWN_MASK to indicate horizontal + * scrolling. We keep track of whether the SHIFT key was actually pressed + * for disambiguation. + */ + private boolean shiftPressed = false; + + /** + * Whether the META key is currently pressed. We need this, because on OS X + * AWT sets the META_DOWN_MASK to for right clicks. We keep track of whether + * the META key was actually pressed for disambiguation. + */ + private boolean metaPressed = false; + + /** + * Whether the WINDOWS key is currently pressed. + */ + private boolean winPressed = false; + + public TIntSet pressedKeys() + { + return pressedKeys; + } + + public boolean shiftPressed() + { + return shiftPressed; + } + + public boolean metaPressed() + { + return metaPressed; + } + + public boolean winPressed() + { + return winPressed; + } + + @Override + public boolean dispatchKeyEvent( final KeyEvent e ) + { + final int id = e.getID(); + if ( id == KeyEvent.KEY_PRESSED ) + { + if ( e == probeEvent ) + { + probeInstalled = true; + return true; + } + else + keyPressed( e ); + } + else if ( id == KeyEvent.KEY_RELEASED ) + keyReleased( e ); + return false; + } + + private void keyPressed( final KeyEvent e ) + { + if ( e.getKeyCode() == KeyEvent.VK_SHIFT ) + { + shiftPressed = true; + } + else if ( e.getKeyCode() == KeyEvent.VK_META ) + { + metaPressed = true; + } + else if ( e.getKeyCode() == KeyEvent.VK_WINDOWS ) + { + winPressed = true; + } + else if ( + e.getKeyCode() != 0 && + e.getKeyCode() != KeyEvent.VK_ALT && + e.getKeyCode() != KeyEvent.VK_CONTROL && + e.getKeyCode() != KeyEvent.VK_ALT_GRAPH ) + { + pressedKeys.add( e.getKeyCode() ); + } + } + + private void keyReleased( final KeyEvent e ) + { + if ( e.getKeyCode() == KeyEvent.VK_SHIFT ) + { + shiftPressed = false; + } + else if ( e.getKeyCode() == KeyEvent.VK_META ) + { + metaPressed = false; + } + else if ( e.getKeyCode() == KeyEvent.VK_WINDOWS ) + { + winPressed = false; + } + else if ( + e.getKeyCode() != 0 && + e.getKeyCode() != KeyEvent.VK_ALT && + e.getKeyCode() != KeyEvent.VK_CONTROL && + e.getKeyCode() != KeyEvent.VK_ALT_GRAPH ) + { + pressedKeys.remove( e.getKeyCode() ); + } + } + + private GlobalKeyEventDispatcher() + {} + + private static final GlobalKeyEventDispatcher instance = new GlobalKeyEventDispatcher(); + + /** + * This is an event we send to the + * {@link KeyboardFocusManager#getCurrentKeyboardFocusManager()} to see if + * we will receive it. Then we know that we're installed... + */ + private static KeyEvent probeEvent = new KeyEvent( new Component() {}, KeyEvent.KEY_PRESSED, 0, 0, 0, 'a' ); + + private static boolean probeInstalled = false; + + private final static PropertyChangeListener clearOnApplicationFocusGained = new PropertyChangeListener() + { + private long focuslosttime; + + @Override + public void propertyChange( final PropertyChangeEvent evt ) + { + final long t = System.currentTimeMillis(); + if ( evt.getNewValue() == null ) + focuslosttime = t; + else if ( evt.getOldValue() == null && t - focuslosttime > 3 ) + { + instance.shiftPressed = false; + instance.metaPressed = false; + instance.winPressed = false; + instance.pressedKeys.clear(); + } + } + }; + + /** + * Install a {@link GlobalKeyEventDispatcher} into the current + * {@link KeyboardFocusManager}, if it is not installed yet. + */ + private static void install() + { + final KeyboardFocusManager focusManager = KeyboardFocusManager.getCurrentKeyboardFocusManager(); + probeInstalled = false; + focusManager.dispatchEvent( probeEvent ); + if ( !probeInstalled ) + { + focusManager.addKeyEventDispatcher( instance ); + focusManager.addPropertyChangeListener( "activeWindow", clearOnApplicationFocusGained ); + } + } +} diff --git a/src/main/java/org/scijava/ui/behaviour/InputTrigger.java b/src/main/java/org/scijava/ui/behaviour/InputTrigger.java new file mode 100644 index 0000000..3413973 --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/InputTrigger.java @@ -0,0 +1,577 @@ +/*- + * #%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.scijava.ui.behaviour; + +import java.awt.AWTKeyStroke; +import java.awt.event.InputEvent; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.StringTokenizer; + +import javax.swing.KeyStroke; + +import gnu.trove.TIntCollection; +import gnu.trove.iterator.TIntIterator; +import gnu.trove.set.TIntSet; +import gnu.trove.set.hash.TIntHashSet; + +/** + * A combination of keys, mouse buttons, and/or mouse scrolling that can trigger a {@link Behaviour}. + * + *

+ * Parsing String in {@link #getFromString(String)} borrows heavily from {@link AWTKeyStroke}. + * + * @author Tobias Pietzsch <tobias.pietzsch@gmail.com> + */ +public class InputTrigger +{ + public static final int SHIFT_DOWN_MASK = 1 << 6; // == InputEvent.SHIFT_DOWN_MASK + public static final int CTRL_DOWN_MASK = 1 << 7; // == InputEvent.CTRL_DOWN_MASK + public static final int META_DOWN_MASK = 1 << 8; // == InputEvent.META_DOWN_MASK + public static final int ALT_DOWN_MASK = 1 << 9; // == InputEvent.ALT_DOWN_MASK + public static final int BUTTON1_DOWN_MASK = 1 << 10; // == InputEvent.BUTTON1_DOWN_MASK + public static final int BUTTON2_DOWN_MASK = 1 << 11; // == InputEvent.BUTTON2_DOWN_MASK + public static final int BUTTON3_DOWN_MASK = 1 << 12; // == InputEvent.BUTTON3_DOWN_MASK + public static final int ALT_GRAPH_DOWN_MASK = 1 << 13; // == InputEvent.ALT_GRAPH_DOWN_MASK + + public static final int DOUBLE_CLICK_MASK = 1 << 20; + public static final int SCROLL_MASK = 1 << 21; + public static final int WIN_DOWN_MASK = 1 << 22; + + public static final int IGNORE_MASK = -1; + + private static final TIntSet emptySet = new TIntHashSet(); + + public static final InputTrigger NOT_MAPPED = new InputTrigger( IGNORE_MASK, emptySet, null, false, IGNORE_MASK, emptySet ); + + /** + * Word to use to specify a double-click modifier. + */ + private static final String DOUBLE_CLICK_TEXT = "double-click"; + + /** + * Word to use to specify scrolling. + */ + private static final String SCROLL_TEXT = "scroll"; + + /** + * Word used to specify the windows key. + */ + private static final String WINDOWS_TEXT= "win"; + + /** + * String used to specify a special {@code NOT_MAPPED} trigger that blocks + * all triggers for an action. + */ + private static final String NOT_MAPPED_TEXT = "not mapped"; + + private static final String IGNORE_ALL_TEXT = "all"; + + private final int mask; + + private final TIntSet pressedKeys; + + private final KeyStroke keyStroke; + + /** + * No modifiers or keys will be ignored. Only exact combination of + * {@link #mask} and {@link #pressedKeys} matches. + */ + private final boolean ignoreNone; + + /** + * Ignore all additional mask bits and pressed keys when matching this + * trigger. + */ + private final boolean ignoreAll; + + /** + * Additional mask bits that are ignored when matching this trigger. Only + * considered if {@code ignoreAll == false} and {@code ignoreNone == false}. + */ + private final int ignoreMask; + + /** + * Additional keys that may be pressed when matching this trigger. Only + * considered if {@code ignoreAll == false} and {@code ignoreNone == false}. + */ + private final TIntSet ignoreKeys; + + /** + * {@code effectiveIgnoreKeys == ignoreKeys \ pressedKeys} + */ + private final TIntSet effectiveIgnoreKeys; + + private final int hashcode; + + public static InputTrigger getFromString( final String s ) throws IllegalArgumentException + { + if ( s == null || s.length() == 0 ) + return null; + + if ( s.equals( NOT_MAPPED_TEXT ) ) + return NOT_MAPPED; + + try + { + final String[] split = splitAndTrim( s ); + final String triggerdef = split[ 0 ]; + final String ignoredef = split.length > 1 ? split[ 1 ] : null; + + MaskAndKeys mak = getMaskAndKeysFromString( triggerdef ); + final int mask = mak.mask; + final TIntSet pressedKeys = mak.pressedKeys; + + /* + * TODO: KeyStroke only if no ignore keys are given???? + */ + KeyStroke keyStroke = null; + if ( ( mask & ( DOUBLE_CLICK_MASK | BUTTON1_DOWN_MASK | BUTTON2_DOWN_MASK | BUTTON3_DOWN_MASK ) ) == 0 ) + { + // no mouse keys, no double-click -- pure keystroke + // This might still fail if for example "win" modifier is present. + // In that case KeyStroke.getKeyStroke( s ) == null. + keyStroke = KeyStroke.getKeyStroke( triggerdef ); + } + + // is there a definition of keys to ignore? + int ignoreMask = 0; + TIntSet ignoreKeys = emptySet; + boolean ignoreAll = false; + if ( ignoredef != null ) + { + if ( ignoredef.equals( IGNORE_ALL_TEXT ) ) + { + ignoreAll = true; + } + else + { + mak = getMaskAndKeysFromString( ignoredef ); + ignoreMask = mak.mask; + ignoreKeys = mak.pressedKeys; + } + } + + // don't keep identical (wrt. equals()) InputTrigger instance around. + return getCached( new InputTrigger( mask, pressedKeys, keyStroke, ignoreAll, ignoreMask, ignoreKeys ) ); + } + catch ( final IllegalArgumentException e ) + { + throw new IllegalArgumentException( "InputTrigger String \"" + s + "\" is formatted incorrectly" ); + } + } + + private static String[] splitAndTrim( final String s ) throws IllegalArgumentException + { + final String[] split = s.split( "\\|" ); + if ( split.length < 1 || split.length > 2 ) + throw new IllegalArgumentException(); + for ( int i = 0; i < split.length; ++i ) + { + split[ i ] = split[ i ].trim(); + if ( split[ i ].length() == 0 ) + throw new IllegalArgumentException(); + } + return split; + } + + private static class MaskAndKeys + { + int mask; + + TIntSet pressedKeys; + + public MaskAndKeys( final int mask, final TIntSet pressedKeys ) + { + this.mask = mask; + this.pressedKeys = pressedKeys; + } + } + + private static MaskAndKeys getMaskAndKeysFromString( final String s ) throws IllegalArgumentException + { + if ( s == null || s.length() == 0 ) + return null; + + final StringTokenizer st = new StringTokenizer( s, " " ); + final Map< String, Integer > modifierKeywords = getModifierKeywords(); + + int mask = 0; + final TIntSet pressedKeys = new TIntHashSet(); + + final int count = st.countTokens(); + + for ( int i = 1; i <= count; i++ ) + { + final String token = st.nextToken(); + if ( token.equals( "released" ) ) + {} + else if ( token.equals( "pressed" ) ) + {} + else if ( token.equals( "typed" ) ) + {} + else + { + final Integer tokenMask = modifierKeywords.get( token ); + if ( tokenMask != null ) + { + mask |= tokenMask.intValue(); + } + else + { + final KeyStroke ks = KeyStroke.getKeyStroke( token ); + if ( ks == null || ks.getKeyCode() == 0 ) + throw new IllegalArgumentException(); + pressedKeys.add( ks.getKeyCode() ); + } + } + } + + return new MaskAndKeys( mask, pressedKeys ); + } + + private InputTrigger( + final int mask, + final TIntSet pressedKeys, + final KeyStroke keyStroke, + final boolean ignoreAll, + final int ignoreMask, + final TIntSet ignoreKeys ) + { + this.mask = mask; + this.pressedKeys = pressedKeys; + this.keyStroke = keyStroke; + this.ignoreNone = !ignoreAll && ignoreMask == 0 && ignoreKeys.isEmpty(); + this.ignoreAll = ignoreAll; + this.ignoreMask = ignoreMask; + this.ignoreKeys = ignoreKeys; + + // TODO add flag that says: nothing is ignored and then only do the default check in matches() + + if ( ignoreAll ) + { + effectiveIgnoreKeys = null; + } + else + { + effectiveIgnoreKeys = new TIntHashSet( ignoreKeys ); + effectiveIgnoreKeys.removeAll( pressedKeys ); + } + + int value = 17; + value = 31 * value + mask; + value = 31 * value + pressedKeys.hashCode(); + value = 31 * value + ignoreMask; + value = 31 * value + ignoreKeys.hashCode(); + value += ignoreAll ? 1 : 0; + hashcode = value; + } + +// TODO: remove +// private InputTrigger( final int mask, final TIntSet pressedKeys, final KeyStroke keyStroke ) +// { +// this.mask = mask; +// this.pressedKeys = pressedKeys; +// this.keyStroke = keyStroke; +// +// int value = 17; +// value = 31 * value + mask; +// value = 31 * value + pressedKeys.hashCode(); +// hashcode = value; +// } + + /** + * Get the modifier mask for this trigger. The mask can have the following + * bits: + *

+ * + * @return get the modifier mask for this trigger. + */ + public int getMask() + { + return mask; + } + + public TIntCollection getPressedKeys() + { + return pressedKeys; + } + + public boolean isKeyTriggered() + { + return ( mask & ( BUTTON1_DOWN_MASK | BUTTON2_DOWN_MASK | BUTTON3_DOWN_MASK | SCROLL_MASK ) ) == 0; + } + + public boolean isKeyStroke() + { + return keyStroke != null; + } + + public KeyStroke getKeyStroke() + { + return keyStroke; + } + + public boolean matches( final int mask, final TIntSet keys ) + { + return matches( mask, keys, new TIntHashSet() ); + } + + public boolean matches( final int mask, final TIntSet keys, final TIntSet tmp ) + { + // C = currently pressed keys + // T = trigger keys (expected to be pressed) + // I = ignored keys (not expected necessarily, but maybe present) + // + // T == C\(I\T) = C & ~(I & ~T) + // expected keys == currently pressed keys with those removed that are + // not expected (but may be present) + // + // C = mask, I = this.ignoreMask, T = this.mask + // C = keys, I = this.ignoreKeys, T = this.pressedKeys + + if ( ignoreNone ) + { + if ( this.mask != mask ) + return false; + else + return keys.equals( pressedKeys ); + } + if ( ignoreAll ) + { + return matchesSubset( mask, keys ); + } + else + { + if ( this.mask != ( mask & ( ~this.ignoreMask | this.mask ) ) ) + return false; + else + { + final TIntSet effectiveKeys = tmp; + effectiveKeys.clear(); + effectiveKeys.addAll( keys ); + effectiveKeys.removeAll( effectiveIgnoreKeys ); + return pressedKeys.equals( effectiveKeys ); + } + } + } + + public boolean matchesSubset( final int mask, final TIntSet keys ) + { + return matchesSubset( mask, keys, new TIntHashSet() ); + } + + public boolean matchesSubset( final int mask, final TIntSet keys, final TIntSet tmp ) + { + // C = currently pressed keys + // T = trigger keys (expected to be pressed) + // + // T == T & C + // + // C = mask, T = this.mask + // C = keys, T = this.pressedKeys + + if ( this.mask != ( mask & this.mask ) ) + return false; + else + { + final TIntSet effectiveKeys = tmp; + effectiveKeys.clear(); + effectiveKeys.addAll( keys ); + effectiveKeys.retainAll( pressedKeys ); + return pressedKeys.equals( effectiveKeys ); + } + } + + @Override + public int hashCode() + { + return hashcode; + } + + @Override + public boolean equals( final Object obj ) + { + if ( obj == this ) + return true; + + if ( ! InputTrigger.class.isInstance( obj ) ) + return false; + + final InputTrigger o = ( InputTrigger ) obj; + return mask == o.mask + && ignoreMask == o.ignoreMask + && pressedKeys.equals( o.pressedKeys ) + && ignoreKeys.equals( o.ignoreKeys ) + && ignoreAll == o.ignoreAll; + } + + /* + * String representation. toString() is also used for writing to YAML and JSON files. + */ + + @Override + public String toString() + { + if ( NOT_MAPPED.equals( this ) ) + return NOT_MAPPED_TEXT; + + final StringBuilder buf = new StringBuilder(); + + addModifierTexts( mask, buf ); + addKeys( pressedKeys, buf ); + if ( !ignoreNone ) + { + buf.append( " |" ); + if ( ignoreAll ) + { + buf.append( " " ); + buf.append( IGNORE_ALL_TEXT ); + } + else + { + addModifierTexts( ignoreMask, buf ); + addKeys( ignoreKeys, buf ); + } + } + + return buf.toString(); + } + + private void addKeys( final TIntSet keys, final StringBuilder buf ) + { + final TIntIterator iter = keys.iterator(); + while ( iter.hasNext() ) + { + final int key = iter.next(); + if ( buf.length() > 0 ) + buf.append( " " ); + final String vkName = KeyStroke.getKeyStroke( key, 0 ).toString(); + buf.append( vkName.replace( "pressed ", "" ) ); + } + } + + private void addModifierTexts( final int mask, final StringBuilder buf ) + { + addModifierText( mask, SHIFT_DOWN_MASK, "shift", buf ); + addModifierText( mask, CTRL_DOWN_MASK, "ctrl", buf ); + addModifierText( mask, META_DOWN_MASK, "meta", buf ); + addModifierText( mask, ALT_DOWN_MASK, "alt", buf ); + addModifierText( mask, ALT_GRAPH_DOWN_MASK, "altGraph", buf ); + addModifierText( mask, BUTTON1_DOWN_MASK, "button1", buf ); + addModifierText( mask, BUTTON2_DOWN_MASK, "button2", buf ); + addModifierText( mask, BUTTON3_DOWN_MASK, "button3", buf ); + addModifierText( mask, DOUBLE_CLICK_MASK, DOUBLE_CLICK_TEXT, buf ); + addModifierText( mask, SCROLL_MASK, SCROLL_TEXT, buf ); + addModifierText( mask, WIN_DOWN_MASK, WINDOWS_TEXT, buf ); + } + + private void addModifierText( final int mask, final int flag, final String name, final StringBuilder buf ) + { + if ( ( mask & flag ) != 0 ) + { + if ( buf.length() > 0 ) + buf.append( " " ); + buf.append( name ); + } + } + + /* + * Caching + */ + + private static Map< String, Integer > modifierKeywords; + + private synchronized static Map getModifierKeywords() + { + if ( modifierKeywords == null ) + { + final Map< String, Integer > uninitializedMap = new HashMap<>( 8, 1.0f ); + uninitializedMap.put( "shift", + SHIFT_DOWN_MASK ); + uninitializedMap.put( "control", + CTRL_DOWN_MASK ); + uninitializedMap.put( "ctrl", + CTRL_DOWN_MASK ); + uninitializedMap.put( "meta", + META_DOWN_MASK ); + uninitializedMap.put( "alt", + ALT_DOWN_MASK ); + uninitializedMap.put( "altGraph", + ALT_GRAPH_DOWN_MASK ); + uninitializedMap.put( "button1", + BUTTON1_DOWN_MASK ); + uninitializedMap.put( "button2", + BUTTON2_DOWN_MASK ); + uninitializedMap.put( "button3", + BUTTON3_DOWN_MASK ); + uninitializedMap.put( DOUBLE_CLICK_TEXT, + DOUBLE_CLICK_MASK ); + uninitializedMap.put( SCROLL_TEXT, + SCROLL_MASK ); + uninitializedMap.put( WINDOWS_TEXT, + WIN_DOWN_MASK ); + modifierKeywords = + Collections.synchronizedMap( uninitializedMap ); + } + return modifierKeywords; + } + + private static Map< InputTrigger, InputTrigger > cache; + + private synchronized static InputTrigger getCached( final InputTrigger buttonsAndKeys ) + { + if ( cache == null ) + cache = new HashMap<>(); + + final InputTrigger b = cache.get( buttonsAndKeys ); + if ( b == null ) + { + cache.put( buttonsAndKeys, buttonsAndKeys ); + return buttonsAndKeys; + } + else + return b; + } +} diff --git a/src/main/java/org/scijava/ui/behaviour/InputTriggerAdder.java b/src/main/java/org/scijava/ui/behaviour/InputTriggerAdder.java new file mode 100644 index 0000000..433799a --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/InputTriggerAdder.java @@ -0,0 +1,44 @@ +/*- + * #%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.scijava.ui.behaviour; + +public interface InputTriggerAdder +{ + public interface Factory + { + public InputTriggerAdder inputTriggerAdder( InputTriggerMap map, final String ... contexts ); + } + + public void put( final String behaviourName, final InputTrigger... defaultTriggers ); + + public void put( final String behaviourName, final String... defaultTriggers ); + + public void put( final String behaviourName ); +} diff --git a/src/main/java/bdv/behaviour/InputTriggerMap.java b/src/main/java/org/scijava/ui/behaviour/InputTriggerMap.java similarity index 62% rename from src/main/java/bdv/behaviour/InputTriggerMap.java rename to src/main/java/org/scijava/ui/behaviour/InputTriggerMap.java index 12b4aed..f124357 100644 --- a/src/main/java/bdv/behaviour/InputTriggerMap.java +++ b/src/main/java/org/scijava/ui/behaviour/InputTriggerMap.java @@ -1,4 +1,33 @@ -package bdv.behaviour; +/*- + * #%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.scijava.ui.behaviour; import java.util.HashMap; import java.util.HashSet; @@ -148,11 +177,15 @@ public synchronized void clear() } /** - * Get all bindings defined in this map and its parents. Note the returned - * map not backed by the {@link InputTriggerMap}, i.e., it will not - * reflect changes to the {@link InputTriggerMap}. + * Get all bindings defined in this map and its parents. Note that the + * returned map is not backed by the {@link InputTriggerMap}, i.e., + * it will not reflect changes to the {@link InputTriggerMap}. + *

+ * This differs from {@code getBindings()} in that this method includes the + * bindings defined in the parent. * - * @return all bindings defined in this map and its parents. + * @return all bindings (trigger to set of behaviour keys) defined in this + * map and its parents. */ public synchronized Map< InputTrigger, Set< String > > getAllBindings() { @@ -162,25 +195,45 @@ public synchronized Map< InputTrigger, Set< String > > getAllBindings() else allBindings = new HashMap<>(); + addBindings( allBindings ); + + return allBindings; + } + + /** + * Get all bindings defined in this map. Note that the returned map is + * not backed by the {@link InputTriggerMap}, i.e., it will not + * reflect changes to the {@link InputTriggerMap}. + * + * @return all bindings (trigger to set of behaviour keys) defined in this + * map. + */ + public synchronized Map< InputTrigger, Set< String > > getBindings() + { + final Map< InputTrigger, Set< String > > bindings = new HashMap<>(); + addBindings( bindings ); + return bindings; + } + + private void addBindings( final Map< InputTrigger, Set< String > > bindings ) + { for ( final Entry< InputTrigger, Set< String > > entry : triggerToKeys.entrySet() ) { final InputTrigger inputTrigger = entry.getKey(); if ( entry.getValue() == null || entry.getValue().isEmpty() ) continue; - Set< String > behaviourKeys = allBindings.get( inputTrigger ); + Set< String > behaviourKeys = bindings.get( inputTrigger ); if ( behaviourKeys == null ) { behaviourKeys = new HashSet<>(); - allBindings.put( inputTrigger, behaviourKeys ); + bindings.put( inputTrigger, behaviourKeys ); } behaviourKeys.addAll( entry.getValue() ); } - - return allBindings; } - protected int modCount() + public int modCount() { if ( parent != null ) { diff --git a/src/main/java/org/scijava/ui/behaviour/KeyPressedManager.java b/src/main/java/org/scijava/ui/behaviour/KeyPressedManager.java new file mode 100644 index 0000000..0694064 --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/KeyPressedManager.java @@ -0,0 +1,73 @@ +/*- + * #%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.scijava.ui.behaviour; + +import gnu.trove.set.TIntSet; + +/** + * Distributes KEY_PRESSED events between windows that share the same + * {@link KeyPressedManager}. The goal is to make keyboard click/drag behaviours + * work like mouse click/drag: When a behaviour is initiated with a key press, + * the window under the mouse receives focus and the behaviour is handled there. + * + * @author Tobias Pietzsch + */ +public class KeyPressedManager +{ + public interface KeyPressedReceiver + { + void handleKeyPressed( final KeyPressedReceiver origin, final int mask, final boolean doubleClick, final TIntSet pressedKeys ); + } + + private KeyPressedReceiver active = null; + + public void handleKeyPressed( + final KeyPressedReceiver origin, + final int mask, + final boolean doubleClick, + final TIntSet pressedKeys ) + { + if ( active != null ) + active.handleKeyPressed( origin, mask, doubleClick, pressedKeys ); + else + origin.handleKeyPressed( origin, mask, doubleClick, pressedKeys ); + } + + public void activate( final KeyPressedReceiver handler) + { + active = handler; + } + + public void deactivate( final KeyPressedReceiver handler ) + { + if ( active == handler ) + active = null; + } +} diff --git a/src/main/java/org/scijava/ui/behaviour/KeyStrokeAdder.java b/src/main/java/org/scijava/ui/behaviour/KeyStrokeAdder.java new file mode 100644 index 0000000..d56f187 --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/KeyStrokeAdder.java @@ -0,0 +1,50 @@ +/*- + * #%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.scijava.ui.behaviour; + +import javax.swing.InputMap; +import javax.swing.KeyStroke; + +public interface KeyStrokeAdder +{ + public interface Factory + { + public KeyStrokeAdder keyStrokeAdder( InputMap map, final String ... contexts ); + } + + @Deprecated + public void put( final String actionName, final KeyStroke... defaultKeyStrokes ); + + public void put( final String actionName, final InputTrigger... defaultKeyStrokes ); + + public void put( final String actionName, final String... defaultKeyStrokes ); + + public void put( final String actionName ); +} diff --git a/src/main/java/bdv/behaviour/MouseAndKeyHandler.java b/src/main/java/org/scijava/ui/behaviour/MouseAndKeyHandler.java similarity index 51% rename from src/main/java/bdv/behaviour/MouseAndKeyHandler.java rename to src/main/java/org/scijava/ui/behaviour/MouseAndKeyHandler.java index a77b81b..09c083a 100644 --- a/src/main/java/bdv/behaviour/MouseAndKeyHandler.java +++ b/src/main/java/org/scijava/ui/behaviour/MouseAndKeyHandler.java @@ -1,6 +1,35 @@ -package bdv.behaviour; - -import java.awt.Toolkit; +/*- + * #%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.scijava.ui.behaviour; + +import java.awt.Component; import java.awt.event.FocusEvent; import java.awt.event.FocusListener; import java.awt.event.InputEvent; @@ -12,164 +41,31 @@ import java.awt.event.MouseWheelEvent; import java.awt.event.MouseWheelListener; import java.util.ArrayList; -import java.util.Map.Entry; -import java.util.Set; + +import org.scijava.ui.behaviour.KeyPressedManager.KeyPressedReceiver; import gnu.trove.map.hash.TIntLongHashMap; import gnu.trove.set.TIntSet; import gnu.trove.set.hash.TIntHashSet; - -public class MouseAndKeyHandler - implements KeyListener, MouseListener, MouseWheelListener, MouseMotionListener, FocusListener +public class MouseAndKeyHandler extends AbstractMouseAndKeyHandler + implements KeyListener, MouseListener, MouseWheelListener, MouseMotionListener, FocusListener { - private static final int DOUBLE_CLICK_INTERVAL = getDoubleClickInterval(); - private static final int OSX_META_LEFT_CLICK = InputEvent.BUTTON1_MASK | InputEvent.BUTTON3_MASK | InputEvent.META_MASK; private static final int OSX_ALT_LEFT_CLICK = InputEvent.BUTTON1_MASK | InputEvent.BUTTON2_MASK | InputEvent.ALT_MASK; private static final int OSX_ALT_RIGHT_CLICK = InputEvent.BUTTON3_MASK | InputEvent.BUTTON2_MASK | InputEvent.ALT_MASK | InputEvent.META_MASK; - private static int getDoubleClickInterval() - { - final Object prop = Toolkit.getDefaultToolkit().getDesktopProperty( "awt.multiClickInterval" ); - return prop == null ? 200 : ( Integer ) prop; - } - - private InputTriggerMap inputMap; - - private BehaviourMap behaviourMap; - - private int inputMapExpectedModCount; - - private int behaviourMapExpectedModCount; - - public void setInputMap( final InputTriggerMap inputMap ) - { - this.inputMap = inputMap; - inputMapExpectedModCount = inputMap.modCount() - 1; - } - - public void setBehaviourMap( final BehaviourMap behaviourMap ) - { - this.behaviourMap = behaviourMap; - behaviourMapExpectedModCount = behaviourMap.modCount() - 1; - } - - /* - * Managing internal behaviour lists. - * - * The internal lists only contain entries for Behaviours that can be - * actually triggered with the current InputMap, grouped by Behaviour type, - * such that hopefully lookup from the event handlers is fast, - */ - - static class BehaviourEntry< T extends Behaviour > - { - final InputTrigger buttons; - - final T behaviour; - - BehaviourEntry( - final InputTrigger buttons, - final T behaviour ) - { - this.buttons = buttons; - this.behaviour = behaviour; - } - } - - private final ArrayList< BehaviourEntry< DragBehaviour > > buttonDrags = new ArrayList<>(); - - private final ArrayList< BehaviourEntry< DragBehaviour > > keyDrags = new ArrayList<>(); - - private final ArrayList< BehaviourEntry< ClickBehaviour > > buttonClicks = new ArrayList<>(); - - private final ArrayList< BehaviourEntry< ClickBehaviour > > keyClicks = new ArrayList<>(); - - private final ArrayList< BehaviourEntry< ScrollBehaviour > > scrolls = new ArrayList<>(); - - /** - * Make sure that the internal behaviour lists are up to date. For this, we - * keep track the modification count of {@link #inputMap} and - * {@link #behaviourMap}. If expected mod counts are not matched, call - * {@link #updateInternalMaps()} to rebuild the internal behaviour lists. - */ - private synchronized void update() - { - final int imc = inputMap.modCount(); - final int bmc = behaviourMap.modCount(); - if ( imc != inputMapExpectedModCount || bmc != behaviourMapExpectedModCount ) - { - inputMapExpectedModCount = imc; - behaviourMapExpectedModCount = bmc; - updateInternalMaps(); - } - } - - /** - * Build internal lists buttonDrag, keyDrags, etc from BehaviourMap(?) and - * InputMap(?). The internal lists only contain entries for Behaviours that - * can be actually triggered with the current InputMap, grouped by Behaviour - * type, such that hopefully lookup from the event handlers is fast. - */ - private void updateInternalMaps() - { - buttonDrags.clear(); - keyDrags.clear(); - buttonClicks.clear(); - keyClicks.clear(); - - for ( final Entry< InputTrigger, Set< String > > entry : inputMap.getAllBindings().entrySet() ) - { - final InputTrigger buttons = entry.getKey(); - final Set< String > behaviourKeys = entry.getValue(); - if ( behaviourKeys == null ) - continue; - - for ( final String behaviourKey : behaviourKeys ) - { - final Behaviour behaviour = behaviourMap.get( behaviourKey ); - if ( behaviour == null ) - continue; - - if ( behaviour instanceof DragBehaviour ) - { - final BehaviourEntry< DragBehaviour > dragEntry = new BehaviourEntry<>( buttons, ( DragBehaviour ) behaviour ); - if ( buttons.isKeyTriggered() ) - keyDrags.add( dragEntry ); - else - buttonDrags.add( dragEntry ); - } - else if ( behaviour instanceof ClickBehaviour ) - { - final BehaviourEntry< ClickBehaviour > clickEntry = new BehaviourEntry<>( buttons, ( ClickBehaviour ) behaviour ); - if ( buttons.isKeyTriggered() ) - keyClicks.add( clickEntry ); - else - buttonClicks.add( clickEntry ); - } - else if ( behaviour instanceof ScrollBehaviour ) - { - final BehaviourEntry< ScrollBehaviour > scrollEntry = new BehaviourEntry<>( buttons, ( ScrollBehaviour ) behaviour ); - scrolls.add( scrollEntry ); - } - } - } - } - - - /* * Event handling. Forwards to registered behaviours. */ - + private final GlobalKeyEventDispatcher globalKeys = GlobalKeyEventDispatcher.getInstance(); /** * Which keys are currently pressed. This does not include modifier keys - * Control, Shift, Alt, AltGr, Meta. + * Control, Shift, Alt, AltGr, Meta, Win. */ private final TIntSet pressedKeys = new TIntHashSet( 5, 0.5f, -1 ); @@ -178,26 +74,6 @@ else if ( behaviour instanceof ScrollBehaviour ) */ private final TIntLongHashMap keyPressTimes = new TIntLongHashMap( 100, 0.5f, -1, -1 ); - /** - * Whether the SHIFT key is currently pressed. We need this, because for - * mouse-wheel AWT uses the SHIFT_DOWN_MASK to indicate horizontal - * scrolling. We keep track of whether the SHIFT key was actually pressed - * for disambiguation. - */ - private boolean shiftPressed = false; - - /** - * Whether the META key is currently pressed. We need this, because on OS X - * AWT sets the META_DOWN_MASK to for right clicks. We keep track of whether - * the META key was actually pressed for disambiguation. - */ - private boolean metaPressed = false; - - /** - * Whether the WINDOWS key is currently pressed. - */ - private boolean winPressed = false; - /** * The current mouse coordinates, updated through {@link #mouseMoved(MouseEvent)}. */ @@ -228,22 +104,22 @@ private int getMask( final InputEvent e ) * For scrolling AWT uses the SHIFT_DOWN_MASK to indicate horizontal scrolling. * We keep track of whether the SHIFT key was actually pressed for disambiguation. */ - if ( shiftPressed ) - mask |= InputEvent.SHIFT_DOWN_MASK; + if ( globalKeys.shiftPressed() ) + mask |= InputTrigger.SHIFT_DOWN_MASK; else - mask &= ~InputEvent.SHIFT_DOWN_MASK; + mask &= ~InputTrigger.SHIFT_DOWN_MASK; /* * On OS X AWT sets the META_DOWN_MASK to for right clicks. We keep * track of whether the META key was actually pressed for * disambiguation. */ - if ( metaPressed ) - mask |= InputEvent.META_DOWN_MASK; + if ( globalKeys.metaPressed() ) + mask |= InputTrigger.META_DOWN_MASK; else - mask &= ~InputEvent.META_DOWN_MASK; + mask &= ~InputTrigger.META_DOWN_MASK; - if ( winPressed ) + if ( globalKeys.winPressed() ) mask |= InputTrigger.WIN_DOWN_MASK; /* @@ -253,17 +129,21 @@ private int getMask( final InputEvent e ) * mouseClicked() when BUTTON1 was clicked (although the button is no * longer down at this point). * - * ...but only if its not a MouseWheelEvent because OS X sets button + * ...but only if its not a MOUSE_WHEEL because OS X sets button * modifiers if ALT or META modifiers are pressed. + * + * ...and also only if its not a MOUSE_RELEASED. Otherwise we will not + * be able to detect drag-end because the mask would still match the + * drag trigger. */ - if ( ! ( e instanceof MouseWheelEvent ) ) + if ( e.getID() != MouseEvent.MOUSE_WHEEL && e.getID() != MouseEvent.MOUSE_RELEASED ) { if ( ( modifiers & InputEvent.BUTTON1_MASK ) != 0 ) - mask |= InputEvent.BUTTON1_DOWN_MASK; + mask |= InputTrigger.BUTTON1_DOWN_MASK; if ( ( modifiers & InputEvent.BUTTON2_MASK ) != 0 ) - mask |= InputEvent.BUTTON2_DOWN_MASK; + mask |= InputTrigger.BUTTON2_DOWN_MASK; if ( ( modifiers & InputEvent.BUTTON3_MASK ) != 0 ) - mask |= InputEvent.BUTTON3_DOWN_MASK; + mask |= InputTrigger.BUTTON3_DOWN_MASK; } /* @@ -271,24 +151,24 @@ private int getMask( final InputEvent e ) * that. */ if ( modifiers == OSX_META_LEFT_CLICK ) - mask &= ~InputEvent.BUTTON3_DOWN_MASK; + mask &= ~InputTrigger.BUTTON3_DOWN_MASK; /* * On OS X AWT sets the BUTTON2_DOWN_MASK for alt+left clicks. Fix * that. */ if ( modifiers == OSX_ALT_LEFT_CLICK ) - mask &= ~InputEvent.BUTTON2_DOWN_MASK; + mask &= ~InputTrigger.BUTTON2_DOWN_MASK; /* * On OS X AWT sets the BUTTON2_DOWN_MASK for alt+right clicks. Fix * that. */ if ( modifiers == OSX_ALT_RIGHT_CLICK ) - mask &= ~InputEvent.BUTTON2_DOWN_MASK; + mask &= ~InputTrigger.BUTTON2_DOWN_MASK; /* - * Deal with mous double-clicks. + * Deal with mouse double-clicks. */ if ( e instanceof MouseEvent && ( ( MouseEvent ) e ).getClickCount() > 1 ) @@ -314,11 +194,11 @@ public void mouseDragged( final MouseEvent e ) // System.out.println( e ); update(); - final int x = e.getX(); - final int y = e.getY(); + mouseX = e.getX(); + mouseY = e.getY(); for ( final BehaviourEntry< DragBehaviour > drag : activeButtonDrags ) - drag.behaviour.drag( x, y ); + drag.behaviour().drag( mouseX, mouseY ); } @Override @@ -331,7 +211,7 @@ public void mouseMoved( final MouseEvent e ) mouseY = e.getY(); for ( final BehaviourEntry< DragBehaviour > drag : activeKeyDrags ) - drag.behaviour.drag( mouseX, mouseY ); + drag.behaviour().drag( mouseX, mouseY ); } @Override @@ -353,14 +233,14 @@ public void mouseWheelMoved( final MouseWheelEvent e ) * the SHIFT key is not pressed. With SHIFT pressed, everything is * treated as vertical scrolling. */ - final boolean exShiftMask = ( e.getModifiersEx() & InputEvent.SHIFT_DOWN_MASK ) != 0; - final boolean isHorizontal = !shiftPressed && exShiftMask; + final boolean exShiftMask = ( e.getModifiersEx() & InputTrigger.SHIFT_DOWN_MASK ) != 0; + final boolean isHorizontal = !globalKeys.shiftPressed() && exShiftMask; for ( final BehaviourEntry< ScrollBehaviour > scroll : scrolls ) { - if ( scroll.buttons.matches( mask, pressedKeys ) ) + if ( scroll.buttons().matches( mask, globalKeys.pressedKeys() ) ) { - scroll.behaviour.scroll( wheelRotation, isHorizontal, x, y ); + scroll.behaviour().scroll( wheelRotation, isHorizontal, x, y ); } } } @@ -379,10 +259,10 @@ public void mouseClicked( final MouseEvent e ) final int clickMask = mask & ~InputTrigger.DOUBLE_CLICK_MASK; for ( final BehaviourEntry< ClickBehaviour > click : buttonClicks ) { - if ( click.buttons.matches( mask, pressedKeys ) || - ( clickMask != mask && click.buttons.matches( clickMask, pressedKeys ) ) ) + if ( click.buttons().matches( mask, globalKeys.pressedKeys() ) || + ( clickMask != mask && click.buttons().matches( clickMask, globalKeys.pressedKeys() ) ) ) { - click.behaviour.click( x, y ); + click.behaviour().click( x, y ); } } } @@ -400,9 +280,9 @@ public void mousePressed( final MouseEvent e ) for ( final BehaviourEntry< DragBehaviour > drag : buttonDrags ) { - if ( drag.buttons.matches( mask, pressedKeys ) ) + if ( drag.buttons().matches( mask, globalKeys.pressedKeys() ) ) { - drag.behaviour.init( x, y ); + drag.behaviour().init( x, y ); activeButtonDrags.add( drag ); } } @@ -417,10 +297,17 @@ public void mouseReleased( final MouseEvent e ) final int x = e.getX(); final int y = e.getY(); + final int mask = getMask( e ); + final ArrayList< BehaviourEntry< ? > > ended = new ArrayList<>(); for ( final BehaviourEntry< DragBehaviour > drag : activeButtonDrags ) - drag.behaviour.end( x, y ); - activeButtonDrags.clear(); + if ( !drag.buttons().matchesSubset( mask, globalKeys.pressedKeys() ) ) + { + drag.behaviour().end( x, y ); + ended.add( drag ); + } + activeButtonDrags.removeAll( ended ); + } @Override @@ -428,6 +315,8 @@ public void mouseEntered( final MouseEvent e ) { // System.out.println( "MouseAndKeyHandler.mouseEntered()" ); update(); + if ( keypressManager != null ) + keypressManager.activate( receiver ); } @Override @@ -435,6 +324,8 @@ public void mouseExited( final MouseEvent e ) { // System.out.println( "MouseAndKeyHandler.mouseExited()" ); update(); + if ( keypressManager != null ) + keypressManager.deactivate( receiver ); } @Override @@ -444,19 +335,10 @@ public void keyPressed( final KeyEvent e ) // System.out.println( e ); update(); - if ( e.getKeyCode() == KeyEvent.VK_SHIFT ) - { - shiftPressed = true; - } - else if ( e.getKeyCode() == KeyEvent.VK_META ) - { - metaPressed = true; - } - else if ( e.getKeyCode() == KeyEvent.VK_WINDOWS ) - { - winPressed = true; - } - else if ( + if ( e.getKeyCode() != 0 && + e.getKeyCode() != KeyEvent.VK_SHIFT && + e.getKeyCode() != KeyEvent.VK_META && + e.getKeyCode() != KeyEvent.VK_WINDOWS && e.getKeyCode() != KeyEvent.VK_ALT && e.getKeyCode() != KeyEvent.VK_CONTROL && e.getKeyCode() != KeyEvent.VK_ALT_GRAPH ) @@ -478,28 +360,107 @@ else if ( keyPressTimes.put( e.getKeyCode(), e.getWhen() ); } - final int doubleClickMask = mask | InputTrigger.DOUBLE_CLICK_MASK; - for ( final BehaviourEntry< DragBehaviour > drag : keyDrags ) + if ( keypressManager != null ) + keypressManager.handleKeyPressed( receiver, mask, doubleClick, globalKeys.pressedKeys() ); + else + handleKeyPressed( mask, doubleClick, globalKeys.pressedKeys(), false ); + } + } + + /** + * If non-null, {@link #keyPressed(KeyEvent)} events are forwarded to the + * {@link KeyPressedManager} which in turn forwards to the + * {@link KeyPressedReceiver} of the component currently under the mouse. + * (This requires that the other component is also registered with the + * {@link KeyPressedManager}. + */ + private KeyPressedManager keypressManager = null; + + /** + * Represents this {@link MouseAndKeyHandler} to the {@link #keypressManager}. + */ + private KeyPressedReceiver receiver = null; + + /** + * @param keypressManager + * @param focus + * function that ensures that the component associated to this + * {@link MouseAndKeyHandler} is focused. + */ + public void setKeypressManager( + final KeyPressedManager keypressManager, + final Runnable focus ) + { + this.keypressManager = keypressManager; + this.receiver = new KeyPressedReceiver() + { + @Override + public void handleKeyPressed( final KeyPressedReceiver origin, final int mask, final boolean doubleClick, final TIntSet pressedKeys ) { - if ( !activeKeyDrags.contains( drag ) && - ( drag.buttons.matches( mask, pressedKeys ) || - ( doubleClick && drag.buttons.matches( doubleClickMask, pressedKeys ) ) ) ) - { - drag.behaviour.init( mouseX, mouseY ); - activeKeyDrags.add( drag ); - } + if ( MouseAndKeyHandler.this.handleKeyPressed( mask, doubleClick, pressedKeys, true ) ) + focus.run(); + MouseAndKeyHandler.this.handleKeyPressed( mask, doubleClick, pressedKeys, false ); } + }; + } - for ( final BehaviourEntry< ClickBehaviour > click : keyClicks ) + /** + * @param keypressManager + * @param focusableOwner + * container of this {@link MouseAndKeyHandler}. If key presses + * are forwarded from the {@link KeyPressedManager} while the + * component does not have focus, then + * {@link Component#requestFocus()}. + */ + public void setKeypressManager( + final KeyPressedManager keypressManager, + final Component focusableOwner ) + { + setKeypressManager( keypressManager, () -> { + if ( !focusableOwner.isFocusOwner() ) { - if ( click.buttons.matches( mask, pressedKeys ) || - ( doubleClick && click.buttons.matches( doubleClickMask, pressedKeys ) ) ) - { - click.behaviour.click( mouseX, mouseY ); - } +// focusableOwner.requestFocusInWindow(); + focusableOwner.requestFocus(); + } + } ); + } + + private boolean handleKeyPressed( final int mask, final boolean doubleClick, final TIntSet pressedKeys, final boolean dryRun ) + { + update(); + + final int doubleClickMask = mask | InputTrigger.DOUBLE_CLICK_MASK; + + boolean triggered = false; + + for ( final BehaviourEntry< DragBehaviour > drag : keyDrags ) + { + if ( !activeKeyDrags.contains( drag ) && + ( drag.buttons().matches( mask, pressedKeys ) || + ( doubleClick && drag.buttons().matches( doubleClickMask, pressedKeys ) ) ) ) + { + if ( dryRun ) + return true; + triggered = true; + drag.behaviour().init( mouseX, mouseY ); + activeKeyDrags.add( drag ); } } + + for ( final BehaviourEntry< ClickBehaviour > click : keyClicks ) + { + if ( click.buttons().matches( mask, pressedKeys ) || + ( doubleClick && click.buttons().matches( doubleClickMask, pressedKeys ) ) ) + { + if ( dryRun ) + return true; + triggered = true; + click.behaviour().click( mouseX, mouseY ); + } + } + + return triggered; } @Override @@ -509,28 +470,25 @@ public void keyReleased( final KeyEvent e ) // System.out.println( e ); update(); - if ( e.getKeyCode() == KeyEvent.VK_SHIFT ) - { - shiftPressed = false; - } - else if ( e.getKeyCode() == KeyEvent.VK_META ) - { - metaPressed = false; - } - else if ( e.getKeyCode() == KeyEvent.VK_WINDOWS ) - { - winPressed = false; - } - else if ( + if ( e.getKeyCode() != 0 && + e.getKeyCode() != KeyEvent.VK_SHIFT && + e.getKeyCode() != KeyEvent.VK_META && + e.getKeyCode() != KeyEvent.VK_WINDOWS && e.getKeyCode() != KeyEvent.VK_ALT && e.getKeyCode() != KeyEvent.VK_CONTROL && e.getKeyCode() != KeyEvent.VK_ALT_GRAPH ) { pressedKeys.remove( e.getKeyCode() ); + final int mask = getMask( e ); + final ArrayList< BehaviourEntry< ? > > ended = new ArrayList<>(); for ( final BehaviourEntry< DragBehaviour > drag : activeKeyDrags ) - drag.behaviour.end( mouseX, mouseY ); - activeKeyDrags.clear(); + if ( !drag.buttons().matchesSubset( mask, globalKeys.pressedKeys() ) ) + { + drag.behaviour().end( mouseX, mouseY ); + ended.add( drag ); + } + activeKeyDrags.removeAll( ended ); } } @@ -539,7 +497,6 @@ public void keyTyped( final KeyEvent e ) { // System.out.println( "MouseAndKeyHandler.keyTyped()" ); // System.out.println( e ); - update(); } @Override @@ -547,9 +504,7 @@ public void focusGained( final FocusEvent e ) { // System.out.println( "MouseAndKeyHandler.focusGained()" ); pressedKeys.clear(); - shiftPressed = false; - metaPressed = false; - winPressed = false; + pressedKeys.addAll( globalKeys.pressedKeys() ); } @Override @@ -557,8 +512,5 @@ public void focusLost( final FocusEvent e ) { // System.out.println( "MouseAndKeyHandler.focusLost()" ); pressedKeys.clear(); - shiftPressed = false; - metaPressed = false; - winPressed = false; } } diff --git a/src/main/java/org/scijava/ui/behaviour/ScrollBehaviour.java b/src/main/java/org/scijava/ui/behaviour/ScrollBehaviour.java new file mode 100644 index 0000000..a499e12 --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/ScrollBehaviour.java @@ -0,0 +1,51 @@ +/*- + * #%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.scijava.ui.behaviour; + +public interface ScrollBehaviour extends Behaviour +{ + /** + * The mouse-wheel was scrolled by the specified amount horizontally or + * vertically, while the mouse pointer was at the specified location. + * + * @param wheelRotation + * the number of "clicks" the mouse wheel was rotated, may be + * fractional. negative for scrolling up/right and positive for + * scrolling down/left. + * @param isHorizontal + * {@code true} if scrolling horizontally, {@code false} if + * scrolling vertically. + * @param x + * mouse x. + * @param y + * mouse y. + */ + public void scroll( double wheelRotation, boolean isHorizontal, int x, int y ); +} diff --git a/src/main/java/org/scijava/ui/behaviour/io/InputTriggerConfig.java b/src/main/java/org/scijava/ui/behaviour/io/InputTriggerConfig.java new file mode 100644 index 0000000..ac2af75 --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/io/InputTriggerConfig.java @@ -0,0 +1,570 @@ +/*- + * #%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.scijava.ui.behaviour.io; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map.Entry; +import java.util.Set; + +import javax.swing.InputMap; +import javax.swing.KeyStroke; + +import org.scijava.ui.behaviour.InputTrigger; +import org.scijava.ui.behaviour.InputTriggerAdder; +import org.scijava.ui.behaviour.InputTriggerMap; +import org.scijava.ui.behaviour.KeyStrokeAdder; + +public class InputTriggerConfig implements InputTriggerAdder.Factory, KeyStrokeAdder.Factory +{ + final LinkedHashMap< String, Set< Input > > actionToInputsMap; + + public InputTriggerConfig() + { + actionToInputsMap = new LinkedHashMap<>(); + } + + public InputTriggerConfig( final Collection< InputTriggerDescription > keyMappings ) throws IllegalArgumentException + { + actionToInputsMap = new LinkedHashMap<>(); + + if ( keyMappings == null ) + return; + + for ( final InputTriggerDescription mapping : keyMappings ) + { + final String behaviour = mapping.getAction(); + final HashSet< String > contexts = new HashSet<>(); + contexts.addAll( Arrays.asList( mapping.getContexts() ) ); + final String[] triggers = mapping.getTriggers(); + for ( final String triggerStr : triggers ) + { + final InputTrigger trigger = InputTrigger.getFromString( triggerStr ); + final Input input = new Input( trigger, behaviour, contexts ); + + final Set< Input > inputs = actionToInputsMap.computeIfAbsent( input.behaviour, k -> new LinkedHashSet<>() ); + inputs.add( input ); + } + } + } + + @Override + public InputTriggerAdder inputTriggerAdder( final InputTriggerMap map, final String ... contexts ) + { + return new InputTriggerAdderImp( map, this, contexts ); + } + + @Override + public KeyStrokeAdder keyStrokeAdder( final InputMap map, final String ... contexts ) + { + return new KeyStrokeAdderImp( map, this, contexts ); + } + + public Set< InputTrigger > getInputs( final String behaviourName, final String context ) + { + return getInputs( behaviourName, Collections.singleton( context ) ); + } + + public Set< InputTrigger > getInputs( final String behaviourName, final Set< String > contexts ) + { + final Set< Input > inputs = actionToInputsMap.get( behaviourName ); + final Set< InputTrigger > triggers = new LinkedHashSet<>(); + if ( inputs != null ) + { + for ( final Input input : inputs ) + if ( ! Collections.disjoint( contexts, input.contexts ) ) + triggers.add( input.trigger ); + } + return triggers; + } + + /** + * Creates a pretty printed list of keys, useful in conjunction with + * {@link InputTriggerConfig#getInputs}. + * + * @param triggers + * Set of triggers to be printed. + * + * @return String with formatted content. + */ + public static String prettyPrintInputs( final Set< InputTrigger > triggers ) + { + final StringBuilder sb = new StringBuilder(); + final Iterator< InputTrigger > it = triggers.iterator(); + while ( it.hasNext() ) + { + sb.append( it.next() ); + if ( it.hasNext() ) + sb.append( " or " ); + } + return sb.toString(); + } + + public void clear() + { + actionToInputsMap.clear(); + } + + public void set( final InputTriggerConfig config ) + { + actionToInputsMap.clear(); + for ( final Entry< String, Set< Input > > entry : config.actionToInputsMap.entrySet() ) + { + final String behaviourName = entry.getKey(); + final Set< Input > inputs = new LinkedHashSet<>(); + entry.getValue().forEach( i -> inputs.add( i.copy() ) ); + actionToInputsMap.put( behaviourName, inputs ); + } + } + + public void add( final String trigger, final String behaviourName, final String context ) + { + add( InputTrigger.getFromString( trigger ), behaviourName, context ); + } + + public void add( final InputTrigger trigger, final String behaviourName, final String context ) + { + add( trigger, behaviourName, Collections.singleton( context ) ); + } + + public synchronized void add( final InputTrigger trigger, final String behaviourName, final Collection< String > contexts ) + { + final Set< Input > inputs = actionToInputsMap.computeIfAbsent( behaviourName, k -> new LinkedHashSet<>() ); + for ( final Input input : inputs ) + { + if ( input.trigger.equals( trigger ) ) + { + /* + * the trigger -> behaviour binding already exists. + * just add the new context + */ + input.contexts.addAll( contexts ); + return; + /* + * NB: this assumes that there exists not more than one Input + * record for each (trigger, behaviour) pair. This property is + * maintained by the add/remove implementations. + */ + } + } + + /* + * the trigger -> behaviour binding does not exist. + * add it + */ + inputs.add( new Input( trigger, behaviourName, contexts ) ); + } + + public void remove( final String trigger, final String behaviourName, final String context ) + { + remove( InputTrigger.getFromString( trigger ), behaviourName, context ); + } + + public void remove( final InputTrigger trigger, final String behaviourName, final String context ) + { + remove( trigger, behaviourName, Collections.singleton( context ) ); + } + + public synchronized void remove( final InputTrigger trigger, final String behaviourName, final Collection< String > contexts ) + { + final Set< Input > inputs = actionToInputsMap.get( behaviourName ); + if ( inputs == null ) + return; + + for ( final Input input : inputs ) + { + if ( input.trigger.equals( trigger ) ) + { + // found Input that covers this trigger -> behaviour binding, + // make sure it does not exist for the given context(s) + input.contexts.removeAll( contexts ); + if ( input.contexts.isEmpty() ) + { + // empty context set -> invalid record -> remove it + inputs.remove( input ); + + if ( inputs.isEmpty() ) + actionToInputsMap.remove( behaviourName ); + } + + return; + /* + * NB: this assumes that there exists not more than one Input + * record for each (trigger, behaviour) pair. This property is + * maintained by the add/remove implementations. + */ + } + } + } + + @Override + public String toString() + { + return "InputTriggerConfig{" + new InputTriggerDescriptionsBuilder( this ).getDescriptions() + '}'; + } + + public static class InputTriggerAdderImp implements InputTriggerAdder + { + private final InputTriggerMap map; + + private final InputTriggerConfig config; + + private final Set< String > contexts; + + public InputTriggerAdderImp( + final InputTriggerMap map, + final InputTriggerConfig config, + final Set< String > contexts ) + { + this.map = map; + this.config = config; + this.contexts = contexts; + } + + public InputTriggerAdderImp( + final InputTriggerMap map, + final InputTriggerConfig config, + final String ... contexts ) + { + this.map = map; + this.config = config; + this.contexts = new HashSet<>( Arrays.asList( contexts ) ); + } + + @Override + public void put( final String behaviourName, final InputTrigger ... defaultTriggers ) + { + final Set< InputTrigger > triggers = config.getInputs( behaviourName, contexts ); + if ( !triggers.isEmpty() ) + { + if ( triggers.contains( InputTrigger.NOT_MAPPED ) ) + return; + + for ( final InputTrigger trigger : triggers ) + map.put( trigger, behaviourName ); + } + else if ( defaultTriggers.length > 0 ) + { + if ( defaultTriggers[ 0 ].equals( InputTrigger.NOT_MAPPED ) ) + { + config.add( InputTrigger.NOT_MAPPED, behaviourName, contexts ); + return; + } + + for ( final InputTrigger trigger : defaultTriggers ) + { + config.add( trigger, behaviourName, contexts ); + map.put( trigger, behaviourName ); + } + } + else + { + System.err.println( "Could not assign InputTrigger for \"" + behaviourName + "\". Nothing defined in InputTriggerConfig, and no default given." ); + } + } + + @Override + public void put( final String behaviourName, final String ... defaultTriggers ) + { + final InputTrigger[] triggers = new InputTrigger[ defaultTriggers.length ]; + int i = 0; + for ( final String s : defaultTriggers ) + triggers[ i++ ] = InputTrigger.getFromString( s ); + put( behaviourName, triggers ); + } + + @Override + public void put( final String behaviourName ) + { + put( behaviourName, new InputTrigger[ 0 ] ); + } + } + + public static class KeyStrokeAdderImp implements KeyStrokeAdder + { + private final InputMap map; + + private final InputTriggerConfig config; + + private final Set< String > contexts; + + public KeyStrokeAdderImp( + final InputMap map, + final InputTriggerConfig config, + final Set< String > contexts ) + { + this.map = map; + this.config = config; + this.contexts = contexts; + } + + public KeyStrokeAdderImp( + final InputMap map, + final InputTriggerConfig config, + final String ... contexts ) + { + this.map = map; + this.config = config; + this.contexts = new HashSet<>( Arrays.asList( contexts ) ); + } + + @Deprecated + @Override + public void put( final String actionName, final KeyStroke... defaultKeyStrokes ) + { + final Set< InputTrigger > triggers = config.getInputs( actionName, contexts ); + if ( triggers.contains( InputTrigger.NOT_MAPPED ) ) + return; + + boolean configKeyAdded = false; + for ( final InputTrigger trigger : triggers ) + { + if ( trigger.isKeyStroke() ) + { + map.put( trigger.getKeyStroke(), actionName ); + configKeyAdded = true; + } + } + if ( configKeyAdded ) + return; + + if ( defaultKeyStrokes.length > 0 ) + { + for ( final KeyStroke keyStroke : defaultKeyStrokes ) + { + config.add( InputTrigger.getFromString( keyStroke.toString() ), actionName, contexts ); + map.put( keyStroke, actionName ); + } + } + else + { + System.err.println( "Could not assign KeyStroke for \"" + actionName + "\". Nothing defined in InputTriggerConfig, and no default given." ); + } + } + + @Override + public void put( final String actionName, final InputTrigger... defaultKeyStrokes ) + { + final Set< InputTrigger > triggers = config.getInputs( actionName, contexts ); + if ( triggers.contains( InputTrigger.NOT_MAPPED ) ) + return; + + boolean configKeyAdded = false; + for ( final InputTrigger trigger : triggers ) + { + if ( trigger.isKeyStroke() ) + { + map.put( trigger.getKeyStroke(), actionName ); + configKeyAdded = true; + } + } + if ( configKeyAdded ) + return; + + if ( defaultKeyStrokes.length > 0 ) + { + if ( defaultKeyStrokes[ 0 ].equals( InputTrigger.NOT_MAPPED ) ) + { + config.add( InputTrigger.NOT_MAPPED, actionName, contexts ); + return; + } + + for ( final InputTrigger trigger : defaultKeyStrokes ) + { + if ( trigger.isKeyStroke() ) + { + config.add( trigger, actionName, contexts ); + map.put( trigger.getKeyStroke(), actionName ); + configKeyAdded = true; + } + } + } + + if ( !configKeyAdded ) + System.err.println( "Could not assign KeyStroke for \"" + actionName + "\". Nothing defined in InputTriggerConfig, and no default given." ); + } + + @Override + public void put( final String actionName, final String... defaultKeyStrokes ) + { + final InputTrigger[] keyStrokes = new InputTrigger[ defaultKeyStrokes.length ]; + int i = 0; + for ( final String s : defaultKeyStrokes ) + keyStrokes[ i++ ] = InputTrigger.getFromString( s ); + put( actionName, keyStrokes ); + } + + @Override + public void put( final String actionName ) + { + put( actionName, new KeyStroke[ 0 ] ); + } + } + + /* + * PRIVATE... + */ + + static class Input + { + final InputTrigger trigger; + + final String behaviour; + + final Set< String > contexts; + + Input( + final InputTrigger trigger, + final String behaviour, + final Collection< String > contexts ) + { + this.trigger = trigger; + this.behaviour = behaviour; + this.contexts = new HashSet<>( contexts ); + } + + Input( final Input input ) + { + this.trigger = input.trigger; + this.behaviour = input.behaviour; + this.contexts = new HashSet<>( input.contexts ); + } + + Input copy() + { + return new Input( this ); + } + + @Override + public int hashCode() + { + int value = 17; + value = 31 * value + trigger.hashCode(); + value = 31 * value + behaviour.hashCode(); + value = 31 * value + contexts.hashCode(); + return value; + } + + @Override + public boolean equals( final Object obj ) + { + if ( obj == null || !( obj instanceof Input ) ) + return false; + final Input i = ( Input ) obj; + return i.trigger.equals( trigger ) && i.behaviour.equals( behaviour ) && i.contexts.equals( contexts ); + } + + InputTriggerDescription getDescription() + { + return new InputTriggerDescription( new String[] { trigger.toString() }, behaviour, contexts.toArray( new String[ 0 ] ) ); + } + } + + /* + * creating InputTriggerConfig from InputTriggerMaps and InputMaps + */ + + void addMap( final InputTriggerMap map, final String context ) + { + for ( final Entry< InputTrigger, Set< String > > entry : map.getAllBindings().entrySet() ) + { + final InputTrigger trigger = entry.getKey(); + final Set< String > behaviours = entry.getValue(); + + for ( final String behaviourName : behaviours ) + { + final Set< Input > inputs = actionToInputsMap.computeIfAbsent( behaviourName, k -> new LinkedHashSet<>() ); + + boolean added = false; + for ( final Input input : inputs ) + { + if ( input.trigger.equals( trigger ) ) + { + /* + * the trigger -> behaviour binding already exists. + * just add the new context + */ + input.contexts.add( context ); + added = true; + break; + } + } + + if ( !added ) + { + inputs.add( new Input( + trigger, + behaviourName, + Collections.singleton( context ) ) ); + } + } + } + } + + void addMap( final InputMap map, final String context ) + { + final KeyStroke[] keys = map.allKeys(); + for ( final KeyStroke key : keys ) + { + final InputTrigger trigger = InputTrigger.getFromString( key.toString() ); + final String behaviourName = map.get( key ).toString(); + + final Set< Input > inputs = actionToInputsMap.computeIfAbsent( behaviourName, k -> new LinkedHashSet<>() ); + + boolean added = false; + for ( final Input input : inputs ) + { + if ( input.trigger.equals( trigger ) ) + { + /* + * the trigger -> behaviour binding already exists. + * just add the new context + */ + input.contexts.add( context ); + added = true; + break; + } + } + + if ( !added ) + { + inputs.add( new Input( + trigger, + behaviourName, + Collections.singleton( context ) ) ); + } + } + } +} diff --git a/src/main/java/org/scijava/ui/behaviour/io/InputTriggerDescription.java b/src/main/java/org/scijava/ui/behaviour/io/InputTriggerDescription.java new file mode 100644 index 0000000..7003775 --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/io/InputTriggerDescription.java @@ -0,0 +1,134 @@ +/*- + * #%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.scijava.ui.behaviour.io; + +import java.util.Arrays; +import javax.swing.Action; + +import org.scijava.ui.behaviour.Behaviour; +import org.scijava.ui.behaviour.InputTrigger; + +/** + * IO record describing the mapping of one {@link InputTrigger} to an + * {@link Action} or a {@link Behaviour}. + * + * @author Tobias Pietzsch <tobias.pietzsch@gmail.com> + */ +public class InputTriggerDescription +{ + /** + * String representations of the {@link InputTrigger}s. + */ + private String[] triggers; + + /** + * Key of the {@link Action} or {@link Behaviour}. + */ + private String action; + + /** + * A list of contexts in which this mapping is active. + */ + private String[] contexts; + + public InputTriggerDescription( + final String[] triggers, + final String action, + final String... contexts ) + { + this.triggers = triggers; + this.action = action; + setContexts( contexts ); + } + + public InputTriggerDescription() + {} + + @Override + public String toString() + { + String s = "( trigger = ["; + if ( triggers != null ) + for ( int i = 0; i < triggers.length; ++i ) + s += "\"" + triggers[ i ] + "\"" + ( i == triggers.length - 1 ? "" : ", " ); + s += "]\n"; + + s += " action = \"" + action + "\"\n"; + + s += " contexts = ["; + if ( contexts != null ) + for ( int i = 0; i < contexts.length; ++i ) + s += "\"" + contexts[ i ] + "\"" + ( i == contexts.length - 1 ? "" : ", " ); + s += "] )\n"; + return s; + } + + public String[] getTriggers() + { + return triggers; + } + + public String getAction() + { + return action; + } + + public String[] getContexts() + { + return contexts; + } + + public void setTriggers( final String[] triggers ) + { + this.triggers = triggers; + } + + public void setAction( final String action ) + { + this.action = action; + } + + public void setContexts( final String[] contexts ) + { + if ( contexts == null || ( contexts.length == 1 && contexts[ 0 ].isEmpty() ) ) + this.contexts = new String[ 0 ]; + else + this.contexts = contexts; + } + + public void addTrigger( final String trigger ) + { + if ( !Arrays.asList( triggers ).contains( trigger ) ) + { + triggers = Arrays.copyOf( triggers, triggers.length + 1 ); + triggers[ triggers.length - 1 ] = trigger; + } + } +} diff --git a/src/main/java/org/scijava/ui/behaviour/io/InputTriggerDescriptionsBuilder.java b/src/main/java/org/scijava/ui/behaviour/io/InputTriggerDescriptionsBuilder.java new file mode 100644 index 0000000..5f46d96 --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/io/InputTriggerDescriptionsBuilder.java @@ -0,0 +1,117 @@ +/*- + * #%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.scijava.ui.behaviour.io; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map.Entry; +import java.util.Set; + +import javax.swing.InputMap; + +import org.scijava.ui.behaviour.InputTriggerMap; +import org.scijava.ui.behaviour.io.InputTriggerConfig.Input; + +/** + * Creates {@link InputTriggerDescription}s from existing {@link InputMap}s and {@link InputTriggerMap}s. + * This can be used to dump the current key and mouse configuration of an application to a config file. + * + * @author Tobias Pietzsch <tobias.pietzsch@gmail.com> + */ +public class InputTriggerDescriptionsBuilder +{ + private final InputTriggerConfig config; + + public InputTriggerDescriptionsBuilder() + { + this( new InputTriggerConfig() ); + } + + public InputTriggerDescriptionsBuilder( final InputTriggerConfig config ) + { + this.config = config; + } + + public List< InputTriggerDescription > getDescriptions() + { + final ArrayList< InputTriggerDescription > descs = new ArrayList<>(); + + for ( final Set< Input > inputs : config.actionToInputsMap.values() ) + { + for ( final Input input : inputs ) + { + boolean found = false; + for ( final InputTriggerDescription desc : descs ) + { + if ( input.behaviour.equals( desc.getAction() ) && + input.contexts.equals( new HashSet<>( Arrays.asList( desc.getContexts() ) ) ) ) + { + desc.addTrigger( input.trigger.toString() ); + found = true; + break; + } + } + if ( !found ) + { + descs.add( input.getDescription() ); + } + } + } + + return descs; + } + + public Set< String > getContexts() + { + final Set< String > contexts = new LinkedHashSet<>(); + for ( final Entry< String, Set< Input > > entry : config.actionToInputsMap.entrySet() ) + for ( final Input input : entry.getValue() ) + contexts.addAll( input.contexts ); + return contexts; + } + + public Set< String > getBehaviourNames() + { + return new LinkedHashSet<>( config.actionToInputsMap.keySet() ); + } + + public void addMap( final InputTriggerMap map, final String context ) + { + config.addMap( map, context ); + } + + public void addMap( final InputMap map, final String context ) + { + config.addMap( map, context ); + } +} diff --git a/src/main/java/org/scijava/ui/behaviour/io/gui/Command.java b/src/main/java/org/scijava/ui/behaviour/io/gui/Command.java new file mode 100644 index 0000000..f4a4a13 --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/io/gui/Command.java @@ -0,0 +1,81 @@ +/*- + * #%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.scijava.ui.behaviour.io.gui; + +/** + * Behaviour or action name in a context. + */ +public class Command +{ + private final String name; + + private final String context; + + public Command( final String name, final String context ) + { + if ( name == null || context == null ) + throw new IllegalArgumentException(); + this.name = name; + this.context = context; + } + + public String getName() + { + return name; + } + + public String getContext() + { + return context; + } + + @Override + public boolean equals( final Object o ) + { + if ( this == o ) + return true; + if ( o == null || getClass() != o.getClass() ) + return false; + + final Command command = ( Command ) o; + + if ( !name.equals( command.name ) ) + return false; + return context.equals( command.context ); + } + + @Override + public int hashCode() + { + int result = name.hashCode(); + result = 31 * result + context.hashCode(); + return result; + } +} diff --git a/src/main/java/org/scijava/ui/behaviour/io/gui/CommandDescriptionProvider.java b/src/main/java/org/scijava/ui/behaviour/io/gui/CommandDescriptionProvider.java new file mode 100644 index 0000000..7fd072c --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/io/gui/CommandDescriptionProvider.java @@ -0,0 +1,121 @@ +/*- + * #%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.scijava.ui.behaviour.io.gui; + +import org.scijava.plugin.SciJavaPlugin; + +/** + * Implementations of this interface, annotated with {@code @Plugin}, are + * discovered for automatically adding actions/behaviours to a + * {@link CommandDescriptions} map. + *

+ * (This allows to discover Plugin shortcuts which cannot be hardwired into the + * default keymap.) + */ +public abstract class CommandDescriptionProvider implements SciJavaPlugin +{ + private final String[] expectedContexts; + + private final Scope scope; + + protected CommandDescriptionProvider( final Scope scope, final String... expectedContexts ) + { + this.scope = scope; + this.expectedContexts = expectedContexts; + } + + /** + * The scope describes the application / Fiji plugin that the actions are + * defined in. This is to make it possible to only harvest descriptions + * for the desired scopes. + *

+ * Note that {@code CommandDescriptionProvider} is only used for harvesting + * actions/behaviours for the config dialog. So basically it has nothing to + * do with reality, necessarily. Whether these actions are ever actualized + * depends on other code! The {@code scope} is for which scope + * the config dialog will make the actions configurable. For example, if you + * put {"mastodon"} there, then the {@code InputTriggerConfig} will be picked + * up by a config dialog requesting these scopes. + */ + public Scope getScope() + { + return scope; + } + + /** + * The contexts in which the described actions/behaviours are expected to be + * used. + *

+ * Note that {@code CommandDescriptionProvider} is only used for harvesting + * actions/behaviours for the config dialog. So basically it has nothing to + * do with reality, necessarily. Whether these actions are ever actualized + * depends on other code! The {@code expectedContexts} is for which context + * the config dialog will make the actions configurable. For example, if you + * put {"bdv", "ts"} there, then the {@code InputTriggerConfig} made by the + * config dialog will put InputTriggers with these contexts. + */ + public String[] getExpectedContexts() + { + return expectedContexts; + } + + public abstract void getCommandDescriptions( final CommandDescriptions descriptions ); + + public static class Scope + { + private final String name; + + public Scope( final String name ) + { + this.name = name; + } + + public String getName() + { + return name; + } + + @Override + public boolean equals( final Object o ) + { + if ( this == o ) + return true; + if ( !( o instanceof Scope ) ) + return false; + return name.equals( ( ( Scope ) o ).name ); + } + + @Override + public int hashCode() + { + return name.hashCode(); + } + } +} diff --git a/src/main/java/org/scijava/ui/behaviour/io/gui/CommandDescriptions.java b/src/main/java/org/scijava/ui/behaviour/io/gui/CommandDescriptions.java new file mode 100644 index 0000000..b994597 --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/io/gui/CommandDescriptions.java @@ -0,0 +1,161 @@ +/*- + * #%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.scijava.ui.behaviour.io.gui; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.scijava.ui.behaviour.InputTrigger; +import org.scijava.ui.behaviour.io.InputTriggerConfig; + +/** + * A map linking {@link Command} (action name and context name) to its + * {@link DescriptionAndTriggers} (textual description and default triggers of + * the command). + *

+ * New entries are added using {@link #add(String, String[], String)}. This + * specifies only the action name of the {@code Command}. The context of the + * {@code Command} is the current context of this + * {@code CommandDescriptions}. The current context is changed by + * {@link #setKeyconfigContext(String)}, and then used for subsequently added + * entries. + */ +public final class CommandDescriptions +{ + public static final class DescriptionAndTriggers + { + private final String description; + + private final String[] defaultTriggers; + + public DescriptionAndTriggers( final String description, final String[] defaultTriggers ) + { + this.description = description; + this.defaultTriggers = defaultTriggers; + } + } + + private final Map< Command, DescriptionAndTriggers > descriptions = new LinkedHashMap<>(); + + private String context; + + /** + * Adds a new entry, linking a {@code Command} to textual description and + * default triggers. + * + * @param name + * name of the {@code Action} or {@code Behaviour}. Together with + * the current context name (see + * {@link #setKeyconfigContext(String)}) this specifies a + * {@link Command}. + * @param defaultTriggers + * default {@link InputTrigger}s for the command. + * @param description + * textual description of the command (for displaying in UI). + */ + public void add( final String name, final String[] defaultTriggers, final String description ) + { + final Command c = new Command( name, context ); + final DescriptionAndTriggers cd = new DescriptionAndTriggers( description, defaultTriggers ); + descriptions.put( c, cd ); + } + + /** + * Sets the current context. This context name is then used for subsequently + * {@link #add(String, String[], String) added} commands. + * + * @param context + * the context name. + */ + public void setKeyconfigContext( final String context ) + { + this.context = context; + } + + /** + * Builds a map from {@link Command} to textual description. This is for + * making a keyconfig {@link VisualEditorPanel}. + * + * @return a new map. + */ + public Map< Command, String > createCommandDescriptionsMap() + { + final Map< Command, String > map = new LinkedHashMap<>(); + descriptions.forEach( ( c, d ) -> map.put( c, d.description ) ); + return map; + } + + /** + * Builds a {@link InputTriggerConfig} with all commands and their default + * triggers. Commands that have no specified default triggers will have + * trigger {@code "not mapped"}. + * + * @return a new {@link InputTriggerConfig}. + */ + public InputTriggerConfig createDefaultKeyconfig() + { + final InputTriggerConfig config = new InputTriggerConfig(); + descriptions.forEach( ( c, d ) -> { + final String name = c.getName(); + final String context = c.getContext(); + final String[] triggers = d.defaultTriggers; + if ( triggers == null || triggers.length == 0 ) + config.add( "not mapped", name, context ); + else + Arrays.stream( triggers ).forEachOrdered( t -> config.add( t, name, context ) ); + } ); + return config; + } + + /** + * For commands that are not yet defined in {@code config}, add them with + * their default triggers. Commands that have no specified default triggers + * will have trigger {@code "not mapped"}. + * + * @param config + * the input trigger config to add commands to. + */ + public void augmentInputTriggerConfig( final InputTriggerConfig config ) + { + descriptions.forEach( ( c, d ) -> { + final String name = c.getName(); + final String context = c.getContext(); + if ( config.getInputs( name, context ).isEmpty() ) + { + final String[] triggers = d.defaultTriggers; + if ( triggers == null || triggers.length == 0 ) + config.add( "not mapped", name, context ); + else + Arrays.stream( triggers ).forEachOrdered( t -> config.add( t, name, context ) ); + } + } ); + } +} diff --git a/src/main/java/org/scijava/ui/behaviour/io/gui/CommandDescriptionsBuilder.java b/src/main/java/org/scijava/ui/behaviour/io/gui/CommandDescriptionsBuilder.java new file mode 100644 index 0000000..e40dfe7 --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/io/gui/CommandDescriptionsBuilder.java @@ -0,0 +1,180 @@ +/*- + * #%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.scijava.ui.behaviour.io.gui; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import java.util.function.Predicate; +import org.scijava.AbstractContextual; +import org.scijava.plugin.Parameter; +import org.scijava.plugin.PluginService; + +/** + * Helper class to populate {@link CommandDescriptions} from + * {@link CommandDescriptionProvider}s. + */ +public class CommandDescriptionsBuilder extends AbstractContextual +{ + @Parameter + private PluginService pluginService; + + private static class ProviderAndContext + { + final CommandDescriptionProvider provider; + + final String context; + + public ProviderAndContext( final CommandDescriptionProvider provider, final String context ) + { + this.provider = provider; + this.context = context; + } + } + + private final List< ProviderAndContext > registered = new ArrayList<>(); + + /** + * Manually adds a {@code provider} in a specified {@code context}. For + * example, this is useful for adding stuff in a specific order, for + * building nice {@code keyconfig.yaml} files. + * + * @param provider + * the provider to add. + * @param context + * the context to add to. + */ + public void addManually( final CommandDescriptionProvider provider, final String context ) + { + for ( final ProviderAndContext pac : registered ) + { + if ( pac.context.equals( context ) && pac.provider.getClass().equals( provider.getClass() ) ) + System.err.println( "Potential problem: a provider of class " + provider.getClass() + " is already registered for context \"" + context + "\"." ); + } + registered.add( new ProviderAndContext( provider, context ) ); + } + + /** + * Manually add a {@code provider} in the specified {@code contexts}. For + * example, this is useful for adding stuff in a specific order, for + * building nice {@code keyconfig.yaml} files. + * + * @param provider + * the provider to add. + * @param contexts + * the list of contexts to add to. + */ + public void addManually( final CommandDescriptionProvider provider, final String ... contexts ) + { + Arrays.stream( contexts ).forEachOrdered( context -> addManually( provider, context ) ); + } + + /** + * Adds all {@link CommandDescriptionProvider}s on the plugin index, with + * their respective {@link CommandDescriptionProvider#getExpectedContexts() + * expected contexts}. + */ + public void discoverProviders() + { + discoverProviders( p -> true ); + } + + /** + * Adds all {@link CommandDescriptionProvider}s on the plugin index having + * any of the given {@code scopes}, with their respective {@link + * CommandDescriptionProvider#getExpectedContexts() expected contexts}. + */ + public void discoverProviders( final CommandDescriptionProvider.Scope ... scopes ) + { + discoverProviders( provider -> Arrays.asList( scopes ).contains( provider.getScope() ) ); + } + + /** + * Adds all {@link CommandDescriptionProvider}s on the plugin index that + * match the given {@code predicate}, with their respective {@link + * CommandDescriptionProvider#getExpectedContexts() expected contexts}. + */ + public void discoverProviders( final Predicate< CommandDescriptionProvider > predicate ) + { + final List< CommandDescriptionProvider > providers = pluginService.createInstancesOfType( CommandDescriptionProvider.class ); + for ( final CommandDescriptionProvider provider : providers ) + if ( predicate.test( provider ) ) + for ( final String context : provider.getExpectedContexts() ) + registered.add( new ProviderAndContext( provider, context ) ); + } + + /** + * Debugging helper. Checks whether all manually added providers are + * automatically discovered, and vice versa. Prints warnings to stderr + * otherwise. + */ + public void verifyManuallyAdded() + { + final List< ProviderAndContext > discovered = new ArrayList<>(); + final List< CommandDescriptionProvider > providers = pluginService.createInstancesOfType( CommandDescriptionProvider.class ); + for ( final CommandDescriptionProvider provider : providers ) + for ( final String context : provider.getExpectedContexts() ) + discovered.add( new ProviderAndContext( provider, context ) ); + + // Can all registered providers be discovered? + boolean anyFailed = false; + A: for ( final ProviderAndContext r : registered ) + { + for ( final ProviderAndContext d : discovered ) + if ( r.context.equals( d.context ) && r.provider.getClass().equals( d.provider.getClass() ) ) + continue A; + System.err.println( r.provider.getClass() + " (\"" + r.context + "\") is manually registered, but could not be discovered." ); + anyFailed = true; + } + if ( anyFailed ) + System.err.println(); + + // Were all discovered providers manually added? + A: for ( final ProviderAndContext d : discovered ) + { + for ( final ProviderAndContext r : registered ) + if ( r.context.equals( d.context ) && r.provider.getClass().equals( d.provider.getClass() ) ) + continue A; + System.err.println( d.provider.getClass() + " (\"" + d.context + "\") was discovered, but was not manually registered." ); + } + } + + public CommandDescriptions build() + { + final CommandDescriptions descriptions = new CommandDescriptions(); + for ( final ProviderAndContext pac : registered ) + { + descriptions.setKeyconfigContext( pac.context ); + pac.provider.getCommandDescriptions( descriptions ); + } + return descriptions; + } +} diff --git a/src/main/java/org/scijava/ui/behaviour/io/gui/InputTriggerPanelEditor.java b/src/main/java/org/scijava/ui/behaviour/io/gui/InputTriggerPanelEditor.java new file mode 100644 index 0000000..801842f --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/io/gui/InputTriggerPanelEditor.java @@ -0,0 +1,669 @@ +/*- + * #%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.scijava.ui.behaviour.io.gui; + +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.KeyboardFocusManager; +import java.awt.event.ActionEvent; +import java.awt.event.KeyAdapter; +import java.awt.event.KeyEvent; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import javax.swing.AbstractAction; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JTextField; +import javax.swing.KeyStroke; +import javax.swing.SwingUtilities; +import javax.swing.UIManager; +import javax.swing.border.EmptyBorder; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.text.BadLocationException; + +import org.scijava.ui.behaviour.InputTrigger; + +import static org.scijava.ui.behaviour.io.gui.TagPanelEditor.mix; + +public class InputTriggerPanelEditor extends JPanel +{ + + @FunctionalInterface + public static interface InputTriggerChangeListener + { + public void inputTriggerChanged(); + } + + private static final long serialVersionUID = 1L; + + private static final String COMMIT_ACTION = "commit"; + + private final List< KeyItem > keyItems; + + private final JTextField textField; + + private final HashSet< InputTriggerChangeListener > listeners; + + private InputTrigger trigger = InputTrigger.NOT_MAPPED; + + private InputTrigger lastValidInputTrigger = InputTrigger.NOT_MAPPED; + + private String invalidTriggerStr = null; + + public InputTriggerPanelEditor( final boolean editable ) + { + this.keyItems = new ArrayList<>(); + this.listeners = new HashSet<>(); + + 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( editable ); + + if ( editable ) + { + final Autocomplete autoComplete = new Autocomplete(); + textField.getDocument().addDocumentListener( autoComplete ); + textField.getInputMap().put( KeyStroke.getKeyStroke( KeyEvent.VK_ENTER, 0 ), COMMIT_ACTION ); + textField.getInputMap().put( KeyStroke.getKeyStroke( ' ' ), COMMIT_ACTION ); + textField.setFocusTraversalKeys( KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS, Collections.emptySet() ); + textField.getInputMap().put( KeyStroke.getKeyStroke( KeyEvent.VK_TAB, 0 ), COMMIT_ACTION ); + textField.getActionMap().put( COMMIT_ACTION, autoComplete.new CommitAction() ); + textField.addKeyListener( new KeyAdapter() + { + @Override + public void keyPressed( final KeyEvent e ) + { + /* + * We have to use a key listener to deal separately with + * character removal and tag removal. + */ + if ( e.getKeyCode() == KeyEvent.VK_BACK_SPACE && textField.getText().isEmpty() && !keyItems.isEmpty() ) + { + removeLastKeyItem(); + e.consume(); + } + } + } ); + } + + add( textField ); + add( Box.createHorizontalGlue() ); + } + + @Override + public void updateUI() + { + super.updateUI(); + setBorder( UIManager.getBorder( "TextField.border" ) ); + setBackground( UIManager.getColor( "TextField.background" ) ); + } + + public void setInputTrigger( final InputTrigger trigger ) + { + this.trigger = trigger; + this.lastValidInputTrigger = trigger; + this.invalidTriggerStr = null; + regenKeyPanels(); + } + + public InputTrigger getInputTrigger() + { + return trigger; + } + + public InputTrigger getLastValidInputTrigger() + { + return lastValidInputTrigger; + } + + private void checkAndAppendKey( final String key ) + { + final String trimmed = key.trim(); + if ( trimmed.equals( "|" ) ) + { + /* + * Special case: the user wants to enter keys to ignore. + */ + + final String str = ( null == trigger || trigger == InputTrigger.NOT_MAPPED ) + ? trimmed + : trigger.toString() + " " + trimmed; + + this.trigger = null; + this.invalidTriggerStr = str.trim(); + regenKeyPanels(); + return; + } + + // Find its proper case equivalent in the syntax list. + String properCapKey = null; + for ( int i = 0; i < INPUT_TRIGGER_SYNTAX_TAGS.size(); i++ ) + { + if ( INPUT_TRIGGER_SYNTAX_TAGS.get( i ).equalsIgnoreCase( trimmed ) ) + { + properCapKey = INPUT_TRIGGER_SYNTAX_TAGS.get( i ); + break; + } + } + + if ( null != properCapKey ) + { + // Some tags are replaced for constructing the trigger, e.g., "cmd" is replaces by "meta" + properCapKey = INPUT_TRIGGER_SYNTAX_TAG_REMAP.getOrDefault( properCapKey, properCapKey ); + + // Try to append the key to the trigger. + final String str = ( null == trigger ) + ? invalidTriggerStr + " " + properCapKey + : ( trigger == InputTrigger.NOT_MAPPED ) + ? properCapKey + : trigger.toString() + " " + properCapKey; + + try + { + this.trigger = InputTrigger.getFromString( str ); + this.lastValidInputTrigger = trigger; + this.invalidTriggerStr = null; + } + catch ( final IllegalArgumentException iae ) + { + this.trigger = null; + this.invalidTriggerStr = str.trim(); + } + regenKeyPanels(); + } + } + + private void removeLastKeyItem() + { + // Try to remove the last key item. + final String[] tokens; + if ( null == trigger ) + { + /* + * The trigger was invalid. In that case we show what would be the + * key sequence in red. + */ + tokens = invalidTriggerStr.split( " " ); + } + else + { + /* + * The trigger field is valid. + */ + tokens = trigger.toString().split( " " ); + } + if ( tokens.length == 0 ) + return; + + // We have to sort tokens as they are displayed. + sortTokens( tokens ); + + final StringBuilder strBlder = new StringBuilder(); + for ( int i = 0; i < tokens.length - 1; i++ ) + strBlder.append( tokens[ i ] + " " ); + + final String str = strBlder.toString(); + try + { + this.trigger = str.isEmpty() + ? InputTrigger.NOT_MAPPED + : InputTrigger.getFromString( str ); + this.lastValidInputTrigger = trigger; + this.invalidTriggerStr = null; + } + catch ( final IllegalArgumentException iae ) + { + this.trigger = null; + this.invalidTriggerStr = str.trim(); + } + regenKeyPanels(); + notifyListeners(); + } + + private void regenKeyPanels() + { + // Clear + for ( final KeyItem keyItem : keyItems ) + remove( keyItem ); + keyItems.clear(); + + final String[] tokens; + boolean valid; + if ( null == trigger ) + { + /* + * The trigger was invalid. In that case we show what would be the + * key sequence in red. + */ + tokens = invalidTriggerStr.split( " " ); + valid = false; + } + else + { + /* + * The trigger field is valid. + */ + + valid = true; + if ( trigger == InputTrigger.NOT_MAPPED ) + tokens = new String[] {}; + else + tokens = trigger.toString().split( " " ); + + } + + if ( tokens.length == 0 ) + { + trigger = InputTrigger.NOT_MAPPED; + lastValidInputTrigger = trigger; + invalidTriggerStr = null; + } + else + { + sortTokens( tokens ); + for ( final String key : tokens ) + { + final KeyItem tagp = new KeyItem( key, valid ); + keyItems.add( tagp ); + add( tagp, getComponentCount() - 2 ); + } + } + + revalidate(); + repaint(); + } + + @Override + public boolean requestFocusInWindow() + { + return textField.requestFocusInWindow(); + } + + /* + * INNER CLASSES + */ + + /** + * Adapted from + * http://stackabuse.com/example-adding-autocomplete-to-jtextfield/ + */ + private class Autocomplete implements DocumentListener + { + + @Override + public void changedUpdate( final DocumentEvent ev ) + {} + + @Override + public void removeUpdate( final DocumentEvent ev ) + {} + + @Override + public void insertUpdate( final DocumentEvent ev ) + { + if ( ev.getLength() != 1 ) + return; + + final int pos = ev.getOffset(); + String content = null; + try + { + content = textField.getText( 0, pos + 1 ); + } + catch ( final BadLocationException e ) + { + e.printStackTrace(); + } + + // Find where the word starts + int w; + for ( w = pos; w >= 0; w-- ) + { + if ( !Character.isLetter( content.charAt( w ) ) ) + { + break; + } + } + + // Too few chars + if ( pos - w < 2 ) + return; + + final String prefix = content.substring( w + 1 ); + // We search on the lower case list. + final int n = Collections.binarySearch( INPUT_TRIGGER_SYNTAX_TAGS_SMALL_CAPS, prefix.toLowerCase() ); + if ( n < 0 && -n <= INPUT_TRIGGER_SYNTAX_TAGS_SORTED.size() ) + { + final String match = INPUT_TRIGGER_SYNTAX_TAGS_SORTED.get( -n - 1 ); + if ( match.toLowerCase().startsWith( prefix.toLowerCase() ) ) + { + // A completion is found + final String completion = match.substring( pos - w ); + // We cannot modify Document from within notification, + // so we submit a task that does the change later + SwingUtilities.invokeLater( new CompletionTask( completion, pos + 1 ) ); + } + } + } + + public class CommitAction extends AbstractAction + { + private static final long serialVersionUID = 5794543109646743416L; + + @Override + public void actionPerformed( final ActionEvent ev ) + { + final String key = textField.getText().trim(); + if ( key.isEmpty() ) + { + KeyboardFocusManager.getCurrentKeyboardFocusManager().focusNextComponent(); + return; + } + checkAndAppendKey( key ); + textField.setText( "" ); + notifyListeners(); + } + } + + private class CompletionTask implements Runnable + { + private final String completion; + + private final int position; + + CompletionTask( final String completion, final int position ) + { + this.completion = completion; + this.position = position; + } + + @Override + public void run() + { + final StringBuffer sb = new StringBuffer( textField.getText() ); + sb.insert( position, completion ); + textField.setText( sb.toString() ); + textField.setCaretPosition( position + completion.length() ); + textField.moveCaretPosition( position ); + } + } + + } + + private final class KeyItem extends JPanel + { + private static final long serialVersionUID = 1L; + + private final boolean valid; + + private 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(); + } + + private 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, InputTriggerPanelEditor.this, 1 ) ); + + Font font = UIManager.getFont( "Label.font" ); + font = font.deriveFont( font.getSize2D() - 2f ); + txt.setFont( font ); + } + } + } + + private void notifyListeners() + { + for ( final InputTriggerChangeListener listener : listeners ) + listener.inputTriggerChanged(); + } + + public void addInputTriggerChangeListener( final InputTriggerChangeListener listener ) + { + listeners.add( listener ); + } + + public void removeInputTriggerChangeListener( final InputTriggerChangeListener listener ) + { + listeners.remove( listener ); + } + + /** 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 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 ); + } +} diff --git a/src/main/java/org/scijava/ui/behaviour/io/gui/RoundBorder.java b/src/main/java/org/scijava/ui/behaviour/io/gui/RoundBorder.java new file mode 100644 index 0000000..f613871 --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/io/gui/RoundBorder.java @@ -0,0 +1,83 @@ +/*- + * #%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.scijava.ui.behaviour.io.gui; + +import java.awt.Color; +import java.awt.Component; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.Rectangle; +import java.awt.RenderingHints; +import java.awt.Shape; +import java.awt.geom.Area; +import java.awt.geom.RoundRectangle2D; + +import javax.swing.border.LineBorder; + +public class RoundBorder extends LineBorder +{ + + private static final long serialVersionUID = 1L; + private final Component parent; + + public RoundBorder( final Color color, final Component parent, final int arc ) + { + super( color, arc ); + this.parent = parent; + } + + @Override + public void paintBorder( final Component c, final Graphics g, final int x, final int y, final int width, final int height ) + { + if ( ( this.thickness > 0 ) && ( g instanceof Graphics2D ) ) + { + final Graphics2D g2d = ( Graphics2D ) g; + final Object oldRenderingHint = g2d.getRenderingHint( RenderingHints.KEY_ANTIALIASING ); + final Color oldColor = g2d.getColor(); + g2d.setRenderingHint( RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON ); + + final float arc = 4f * thickness; + final Shape inner = new RoundRectangle2D.Float( x, y, width - 1, height - 1, (int) arc, (int) arc ); + final Shape outer = new Rectangle( x, y, width, height ); + + g2d.setColor( parent.getBackground() ); + final Area area = new Area(outer); + area.exclusiveOr( new Area(inner) ); + g2d.fill( area ); + + g2d.setColor( this.lineColor ); + g2d.draw( inner ); + + g2d.setColor( oldColor ); + g2d.setRenderingHint( RenderingHints.KEY_ANTIALIASING, oldRenderingHint ); + } + } + +} diff --git a/src/main/java/org/scijava/ui/behaviour/io/gui/TagPanelEditor.java b/src/main/java/org/scijava/ui/behaviour/io/gui/TagPanelEditor.java new file mode 100644 index 0000000..616698e --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/io/gui/TagPanelEditor.java @@ -0,0 +1,434 @@ +/*- + * #%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.scijava.ui.behaviour.io.gui; + +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.KeyboardFocusManager; +import java.awt.event.ActionEvent; +import java.awt.event.KeyAdapter; +import java.awt.event.KeyEvent; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; + +import javax.swing.AbstractAction; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JTextField; +import javax.swing.KeyStroke; +import javax.swing.SwingUtilities; +import javax.swing.UIManager; +import javax.swing.border.EmptyBorder; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.text.BadLocationException; + +public class TagPanelEditor extends JPanel +{ + + @FunctionalInterface + public static interface TagSelectionChangeListener + { + public void tagSelectionChanged(); + } + + private static final long serialVersionUID = 1L; + + private static final String COMMIT_ACTION = "commit"; + + protected final List< String > selectedTags; + + protected final List< TagPanel > tagPanels; + + protected final List< String > tags; + + private final JTextField textField; + + private final boolean editable; + + private final HashSet< TagSelectionChangeListener > listeners; + + private final Map< String, String > printables; + + public TagPanelEditor( final Collection< String > tags ) + { + this( tags, true ); + } + + public TagPanelEditor( final Collection< String > tags, final boolean editable ) + { + this( tags, editable, Collections.emptyMap() ); + } + + public TagPanelEditor( final Collection< String > tags, final boolean editable, final Map< String, String > printables ) + { + this.editable = editable; + this.printables = printables; + this.tags = new ArrayList<>( tags ); + this.tags.sort( null ); + this.selectedTags = new ArrayList<>(); + this.tagPanels = new ArrayList<>(); + this.listeners = new HashSet<>(); + + 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( editable ); + + if ( editable ) + { + final Autocomplete autoComplete = new Autocomplete(); + textField.getDocument().addDocumentListener( autoComplete ); + textField.getInputMap().put( KeyStroke.getKeyStroke( KeyEvent.VK_ENTER, 0 ), COMMIT_ACTION ); + textField.setFocusTraversalKeys( KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS, Collections.emptySet() ); + textField.getInputMap().put( KeyStroke.getKeyStroke( KeyEvent.VK_TAB, 0 ), COMMIT_ACTION ); + textField.getActionMap().put( COMMIT_ACTION, autoComplete.new CommitAction() ); + textField.addKeyListener( new KeyAdapter() + { + @Override + public void keyPressed( final KeyEvent e ) + { + /* + * We have to use a key listener to deal separately with + * character removal and tag removal. + */ + if ( e.getKeyCode() == KeyEvent.VK_BACK_SPACE && textField.getText().isEmpty() && !selectedTags.isEmpty() ) + { + removeTag( selectedTags.get( selectedTags.size() - 1 ) ); + e.consume(); + } + } + } ); + } + + add( textField ); + add( Box.createHorizontalGlue() ); + } + + @Override + public void updateUI() + { + super.updateUI(); + setBorder( UIManager.getBorder( "TextField.border" ) ); + setBackground( UIManager.getColor( "TextField.background" ) ); + } + + public List< String > getSelectedTags() + { + return Collections.unmodifiableList( selectedTags ); + } + + public void setTags( final Collection< String > tags ) + { + for ( final TagPanel tagPanel : tagPanels ) + remove( tagPanel ); + selectedTags.clear(); + tagPanels.clear(); + + for ( final String tag : tags ) + addTag( tag ); + revalidate(); + repaint(); + } + + public void setAcceptableTags( final Collection< String > acceptableTags ) + { + tags.clear(); + tags.addAll( acceptableTags ); + tags.sort( null ); + repaint(); + } + + protected void addTag( final String tag ) + { + final TagPanel tagp = new TagPanel( tag, this.tags.contains( tag ) ); + selectedTags.add( tag ); + tagPanels.add( tagp ); + add( tagp, getComponentCount() - 2 ); + } + + private void removeTag( final String tag ) + { + final int index = selectedTags.indexOf( tag ); + if ( index < 0 ) + return; + + selectedTags.remove( index ); + final TagPanel tagPanel = tagPanels.remove( index ); + notifyListeners(); + remove( tagPanel ); + revalidate(); + repaint(); + } + + /* + * INNER CLASSES + */ + + /** + * Adapted from + * http://stackabuse.com/example-adding-autocomplete-to-jtextfield/ + */ + private class Autocomplete implements DocumentListener + { + + @Override + public void changedUpdate( final DocumentEvent ev ) + {} + + @Override + public void removeUpdate( final DocumentEvent ev ) + {} + + @Override + public void insertUpdate( final DocumentEvent ev ) + { + if ( ev.getLength() != 1 ) + return; + + final int pos = ev.getOffset(); + String content = null; + try + { + content = textField.getText( 0, pos + 1 ); + } + catch ( final BadLocationException e ) + { + e.printStackTrace(); + } + + // Find where the word starts + int w; + for ( w = pos; w >= 0; w-- ) + { + if ( !Character.isLetter( content.charAt( w ) ) ) + { + break; + } + } + + // Too few chars + if ( pos - w < 2 ) + return; + + final String prefix = content.substring( w + 1 ).toLowerCase(); + final int n = Collections.binarySearch( tags, prefix ); + if ( n < 0 && -n <= tags.size() ) + { + final String match = tags.get( -n - 1 ); + if ( match.startsWith( prefix ) ) + { + // A completion is found + final String completion = match.substring( pos - w ); + // We cannot modify Document from within notification, + // so we submit a task that does the change later + SwingUtilities.invokeLater( new CompletionTask( completion, pos + 1 ) ); + } + } + } + + public class CommitAction extends AbstractAction + { + private static final long serialVersionUID = 5794543109646743416L; + + @Override + public void actionPerformed( final ActionEvent ev ) + { + final String tag = textField.getText(); + + if ( tag.isEmpty() ) + { + KeyboardFocusManager.getCurrentKeyboardFocusManager().focusNextComponent(); + return; + } + + if ( selectedTags.contains( tag ) ) + { + // Do not allow more than 1 tag instance. + textField.setText( "" ); + return; + } + + addTag( tag ); + textField.setText( "" ); + notifyListeners(); + revalidate(); + repaint(); + } + } + + private class CompletionTask implements Runnable + { + private final String completion; + + private final int position; + + CompletionTask( final String completion, final int position ) + { + this.completion = completion; + this.position = position; + } + + @Override + public void run() + { + final StringBuffer sb = new StringBuffer( textField.getText() ); + sb.insert( position, completion ); + textField.setText( sb.toString() ); + textField.setCaretPosition( position + completion.length() ); + textField.moveCaretPosition( position ); + } + } + + } + + final class TagPanel extends JPanel + { + private static final long serialVersionUID = 1L; + + private final JPanel content; + + private final boolean valid; + + private final JLabel txt; + + private JLabel close; + + public TagPanel( final String tag, final boolean valid ) + { + this.valid = valid; + + content = new JPanel(); + content.setLayout( new BoxLayout( content, BoxLayout.LINE_AXIS ) ); + content.setOpaque( true ); + + if ( editable ) + { + close = new JLabel( "\u00D7" ); + close.setOpaque( false ); + close.addMouseListener( new java.awt.event.MouseAdapter() + { + @Override + public void mousePressed( final java.awt.event.MouseEvent evt ) + { + removeTag( tag ); + } + } ); + content.add( close ); + content.add( createHorizontalStrutWithMaxHeight1( 2 ) ); + } + + final String str = printables.containsKey( tag ) ? printables.get( tag ) : tag; + txt = new JLabel( str ); + txt.setOpaque( false ); + content.add( txt ); + updateTxtLook(); + + setLayout( new BoxLayout( this, BoxLayout.LINE_AXIS ) ); + add( Box.createHorizontalStrut( 1 ) ); + add( content ); + add( Box.createHorizontalStrut( 4 ) ); + setOpaque( false ); + } + + @Override + public void updateUI() + { + super.updateUI(); + updateTxtLook(); + } + + private void updateTxtLook() + { + if ( content != 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 ); + content.setBackground( bg ); + content.setBorder( new RoundBorder( borderColor, TagPanelEditor.this, 1 ) ); + + Font font = UIManager.getFont( "Label.font" ); + font = font.deriveFont( font.getSize2D() - 2f ); + txt.setFont( font ); + if ( close != null ) + close.setFont( font ); + } + } + } + + private static Box.Filler createHorizontalStrutWithMaxHeight1( final int width ) + { + return new Box.Filler( new Dimension( width, 0 ), new Dimension( width, 0 ), + new Dimension( width, 1 ) ); + } + + + private void notifyListeners() + { + for ( final TagSelectionChangeListener listener : listeners ) + listener.tagSelectionChanged(); + } + + public void addTagSelectionChangeListener( final TagSelectionChangeListener listener ) + { + listeners.add( listener ); + } + + public void removeTagSelectionChangeListener( final TagSelectionChangeListener listener ) + { + listeners.remove( listener ); + } + + /** + * 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 ) ); + } +} diff --git a/src/main/java/org/scijava/ui/behaviour/io/gui/VisualEditorPanel.java b/src/main/java/org/scijava/ui/behaviour/io/gui/VisualEditorPanel.java new file mode 100644 index 0000000..7f56adb --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/io/gui/VisualEditorPanel.java @@ -0,0 +1,1277 @@ +/*- + * #%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.scijava.ui.behaviour.io.gui; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.FlowLayout; +import java.awt.Font; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.awt.KeyboardFocusManager; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.PrintWriter; +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.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Set; +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.JFileChooser; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTable; +import javax.swing.JTextArea; +import javax.swing.JTextField; +import javax.swing.ListSelectionModel; +import javax.swing.RowFilter; +import javax.swing.ScrollPaneConstants; +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.filechooser.FileFilter; +import javax.swing.table.AbstractTableModel; +import javax.swing.table.TableCellRenderer; +import javax.swing.table.TableRowSorter; +import org.scijava.listeners.Listeners; +import org.scijava.ui.behaviour.InputTrigger; +import org.scijava.ui.behaviour.io.InputTriggerConfig; +import org.scijava.ui.behaviour.io.InputTriggerDescription; +import org.scijava.ui.behaviour.io.InputTriggerDescriptionsBuilder; + +public class VisualEditorPanel extends JPanel +{ + + private static final long serialVersionUID = 1L; + + static JFileChooser fileChooser = new JFileChooser(); + + static + { + fileChooser.setFileFilter( new FileFilter() + { + + @Override + public String getDescription() + { + return "CSV files"; + } + + @Override + public boolean accept( final File f ) + { + return f.isFile() && f.getName().toLowerCase().endsWith( ".csv" ); + } + } ); + } + + /** + * Interface for listeners notified when settings are changed in the visual + * editor. + */ + @FunctionalInterface + public interface ConfigChangeListener + { + /** + * Called when settings are changed in the visual editor. + */ + void configChanged(); + } + + private JTextField textFieldFilter; + + private MyTableModel tableModel; + + private TableRowSorter< MyTableModel > tableRowSorter; + + private boolean blockRemoveNotMapped = false; + + private final InputTriggerPanelEditor keybindingEditor; + + private final TagPanelEditor contextsEditor; + + private final JLabel labelCommandName; + + private final JTable tableBindings; + + private final InputTriggerConfig config; + + private final Set< Command > commands; + + private final Map< String, Set< String > > commandNameToAcceptableContexts; + + private final Map< Command, String > actionDescriptions; + + private final JLabel lblConflict; + + private final JTextArea textAreaDescription; + + private final JPanel panelEditor; + + private final JPanel panelButtons; + + /** + * Set of listeners that are triggered whenever any single item change in the GUI. + * This, however, does not mean that the underlying {@link InputTriggerConfig} was + * changed at all as the GUI is buffering changes until the "Apply" button is pressed. + */ + private final Listeners.List< ConfigChangeListener > modelChangedListeners; + + /** + * Set of listeners that are triggered only when the "Apply" button is pressed, + * which is precisely the moment when the current state/content of GUI is committed + * to the underlying {@link InputTriggerConfig} via the ModelToConfig(). + */ + private final Listeners.List< ConfigChangeListener > configCommittedListeners; + + private final JButton btnApply; + + private final JButton btnRestore; + + /** + * Creates a visual editor for an {@link InputTriggerConfig}. The config + * object is directly modified when the user clicks the 'Apply' button. + * + * @param config + * the {@link InputTriggerConfig} object to modify. + */ + public VisualEditorPanel( final InputTriggerConfig config ) + { + this( config, extractEmptyCommandDescriptions( config ) ); + } + + /** + * Creates a visual editor for an {@link InputTriggerConfig}. The config + * object is directly modified when the user clicks the 'Apply' button. + * + * @param config + * the {@link InputTriggerConfig} object to modify. + * @param commandDescriptions + * The commands available. They are specified as a map from + * command to description. Use null as value to not + * specify a description. + * @see CommandDescriptionsBuilder + */ + public VisualEditorPanel( final InputTriggerConfig config, final Map< Command, String > commandDescriptions ) + { + + this.config = config; + this.actionDescriptions = commandDescriptions; + this.commands = commandDescriptions.keySet(); + commandNameToAcceptableContexts = new HashMap<>(); + for ( final Command command : commands ) + commandNameToAcceptableContexts.computeIfAbsent( command.getName(), k -> new HashSet<>() ).add( command.getContext() ); + this.modelChangedListeners = new Listeners.SynchronizedList<>(); + this.configCommittedListeners = 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 ); + 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(); + } + } ); + + panelEditor = new JPanel(); + add( panelEditor, BorderLayout.SOUTH ); + panelEditor.setLayout( new BorderLayout( 0, 0 ) ); + + final JPanel panelCommandButtons = new JPanel(); + panelEditor.add( panelCommandButtons, BorderLayout.NORTH ); + panelCommandButtons.setLayout( new BoxLayout( panelCommandButtons, BoxLayout.X_AXIS ) ); + + final JButton btnCopyCommand = new JButton( "Copy" ); + btnCopyCommand.setToolTipText( "Duplicate command binding to a new, blank binding." ); + panelCommandButtons.add( btnCopyCommand ); + + final JButton btnUnbindAction = new JButton( "Unbind" ); + btnUnbindAction.setToolTipText( "Remove current binding for selected command." ); + panelCommandButtons.add( btnUnbindAction ); + + final JButton btnDeleteAction = new JButton( "Unbind all" ); + btnDeleteAction.setToolTipText( "Remove all bindings to selected command." ); + panelCommandButtons.add( btnDeleteAction ); + + final Component horizontalGlue = Box.createHorizontalGlue(); + panelCommandButtons.add( horizontalGlue ); + + final JButton btnExportCsv = new JButton( "Export CSV" ); + btnExportCsv.setToolTipText( "Export all command bindings to a CSV file." ); + panelCommandButtons.add( btnExportCsv ); + + 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 InputTriggerPanelEditor( true ); + 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 lblContext = new JLabel( "Contexts:" ); + final GridBagConstraints gbc_lblContext = new GridBagConstraints(); + gbc_lblContext.anchor = GridBagConstraints.WEST; + gbc_lblContext.insets = new Insets( 5, 5, 5, 5 ); + gbc_lblContext.gridx = 0; + gbc_lblContext.gridy = 2; + panelCommandEditor.add( lblContext, gbc_lblContext ); + + this.contextsEditor = new TagPanelEditor( Collections.emptyList() ); + final GridBagConstraints gbc_comboBoxContext = new GridBagConstraints(); + gbc_comboBoxContext.insets = new Insets( 5, 5, 5, 5 ); + gbc_comboBoxContext.fill = GridBagConstraints.BOTH; + gbc_comboBoxContext.gridx = 1; + gbc_comboBoxContext.gridy = 2; + panelCommandEditor.add( contextsEditor, gbc_comboBoxContext ); + + final JLabel lblConflicts = new JLabel( "Conflicts:" ); + final GridBagConstraints gbc_lblConflicts = new GridBagConstraints(); + gbc_lblConflicts.insets = new Insets( 5, 5, 5, 5 ); + gbc_lblConflicts.anchor = GridBagConstraints.WEST; + gbc_lblConflicts.gridx = 0; + gbc_lblConflicts.gridy = 3; + panelCommandEditor.add( lblConflicts, gbc_lblConflicts ); + + lblConflict = new JLabel( "" ); + lblConflict.setToolTipText( "Conflicts with other commands." ); + lblConflict.setForeground( Color.PINK.darker() ); + lblConflict.setFont( getFont().deriveFont( Font.BOLD ) ); + final GridBagConstraints gbc_lblConflict = new GridBagConstraints(); + gbc_lblConflict.insets = new Insets( 5, 5, 5, 0 ); + gbc_lblConflict.anchor = GridBagConstraints.WEST; + gbc_lblConflict.gridx = 1; + gbc_lblConflict.gridy = 3; + panelCommandEditor.add( lblConflict, gbc_lblConflict ); + + 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 ); + + panelButtons = new JPanel(); + panelEditor.add( panelButtons, BorderLayout.SOUTH ); + final FlowLayout flowLayout = ( FlowLayout ) panelButtons.getLayout(); + flowLayout.setAlignment( FlowLayout.TRAILING ); + + this.btnRestore = new JButton( "Restore" ); + btnRestore.setToolTipText( "Re-read the key bindings from the config." ); + panelButtons.add( btnRestore ); + + this.btnApply = new JButton( "Apply" ); + btnApply.setToolTipText( "Write these key bindings in the config." ); + 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() { + @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.getSelectionModel().addListSelectionListener( new ListSelectionListener() + { + @Override + public void valueChanged( final ListSelectionEvent e ) + { + if ( e.getValueIsAdjusting() ) + return; + + if ( blockRemoveNotMapped ) + { + blockRemoveNotMapped = false; + return; + } + + final int selIndex = tableBindings.getSelectionModel().getMinSelectionIndex(); + if ( selIndex < 0 ) + return; + final MyTableRow selectedRowToRestore = tableModel.rows.get( selIndex ); + if ( !tableModel.removeSuperfluousNotMapped() ) + return; + + final int bs = Collections.binarySearch( tableModel.rows, selectedRowToRestore, MyTableRowComparator ); + if ( bs < 0 ) + return; + final int vbs = tableBindings.convertRowIndexToView( bs ); + tableBindings.getSelectionModel().setSelectionInterval( vbs, vbs ); + } + } ); + tableBindings.setFocusTraversalKeys( KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS, null ); + tableBindings.setFocusTraversalKeys( KeyboardFocusManager.BACKWARD_TRAVERSAL_KEYS, null ); + + // Listen to changes in the keybinding editor and forward to table + // model. + keybindingEditor.addInputTriggerChangeListener( () -> keybindingsChanged( + keybindingEditor.getInputTrigger() == null + ? keybindingEditor.getLastValidInputTrigger() + : keybindingEditor.getInputTrigger() ) ); + + // Listen to changes in context editor and forward to table model. + contextsEditor.addTagSelectionChangeListener( () -> contextsChanged( contextsEditor.getSelectedTags() ) ); + + // Button presses. + btnCopyCommand.addActionListener( ( e ) -> copyCommand() ); + btnUnbindAction.addActionListener( ( e ) -> unbindCommand() ); + btnDeleteAction.addActionListener( ( e ) -> unbindAllCommand() ); + btnExportCsv.addActionListener( ( e ) -> exportToCsv() ); + btnRestore.addActionListener( ( e ) -> configToModel() ); + btnApply.addActionListener( ( e ) -> modelToConfig() ); + + // Buttons re-enabling when model and config are out of sync. + modelChangedListeners.add( () -> { + btnApply.setEnabled( true ); + btnRestore.setEnabled( true ); + } ); + + configToModel(); + tableBindings.getRowSorter().toggleSortOrder( 0 ); + if ( tableBindings.getRowCount() > 0 ) + tableBindings.getSelectionModel().setSelectionInterval( 0, 0); + + scrollPane.setViewportView( tableBindings ); + } + + private void lookForConflicts() + { + lblConflict.setText( "" ); + + final int viewRow = tableBindings.getSelectedRow(); + if ( viewRow < 0 ) + return; + final int modelRow = tableBindings.convertRowIndexToModel( viewRow ); + + lblConflict.setText( "" ); + final InputTrigger inputTrigger = tableModel.rows.get( modelRow ).getTrigger(); + if ( inputTrigger == InputTrigger.NOT_MAPPED ) + return; + final List< String > contexts = tableModel.rows.get( modelRow ).getContexts(); + + final ArrayList< String > conflicts = new ArrayList<>(); + for ( int i = 0; i < tableModel.getRowCount(); i++ ) + { + if ( i == modelRow ) + continue; + + if ( tableModel.rows.get( i ).getTrigger().equals( inputTrigger ) ) + { + // Same trigger. Check if contexts overlap. + final List< String > overlappingContexts = new ArrayList<>( tableModel.rows.get( i ).getContexts() ); + overlappingContexts.retainAll( contexts ); + if ( !overlappingContexts.isEmpty() ) + { + final StringBuilder str = new StringBuilder(); + str.append( tableModel.rows.get( i ).getName() ); + str.append( " in " ).append( overlappingContexts.get( 0 ) ); + for ( int j = 1; j < overlappingContexts.size(); j++ ) + str.append( ", " ).append( overlappingContexts.get( j ) ); + + conflicts.add( str.toString() ); + } + } + } + + if ( !conflicts.isEmpty() ) + { + final StringBuilder str = new StringBuilder( conflicts.get( 0 ) ); + for ( int i = 1; i < conflicts.size(); i++ ) + str.append( "; " ).append( conflicts.get( i ) ); + lblConflict.setText( str.toString() ); + } + } + + public void setButtonPanelVisible( final boolean visible ) + { + panelEditor.remove( panelButtons ); + if ( visible ) + panelEditor.add( panelButtons, BorderLayout.SOUTH ); + } + + // TODO Change method name to 'apply()'. API breaking change. + /** + * Copies the settings in this editor to the {@link InputTriggerConfig} + * specified at construction. The {@link InputTriggerConfig} is cleared + * before copying. + */ + public void modelToConfig() + { + config.clear(); + for ( final MyTableRow row : tableModel.rows ) + { + final InputTrigger inputTrigger = row.getTrigger(); + if ( inputTrigger == InputTrigger.NOT_MAPPED ) + continue; + + final String action = row.getName(); + config.add( inputTrigger, action, row.getContexts() ); + } + + // fill in InputTrigger.NOT_MAPPED for any action that doesn't have any input + for ( final Command command : commands ) + { + final String action = command.getName(); + if ( config.getInputs( action, command.getContext() ).isEmpty() ) + config.add( InputTrigger.NOT_MAPPED, action, command.getContext() ); + } + + btnApply.setEnabled( false ); + btnRestore.setEnabled( false ); + + configCommittedListeners.list.forEach( ConfigChangeListener::configChanged ); + } + + public void configToModel() + { + tableModel = new MyTableModel( commands, config ); + tableBindings.setModel( tableModel ); + + tableRowSorter = new TableRowSorter<>( tableModel ); + tableRowSorter.setComparator( 1, InputTriggerComparator ); + tableBindings.setRowSorter( tableRowSorter ); + filterRows(); + + // Renderers. + tableBindings.getColumnModel().getColumn( 1 ).setCellRenderer( new MyBindingsRenderer() ); + tableBindings.getColumnModel().getColumn( 2 ).setCellRenderer( new MyContextsRenderer( Collections.emptyList() ) ); + + // Notify listeners. + notifyListeners(); + + btnApply.setEnabled( false ); + btnRestore.setEnabled( false ); + } + + 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 final static String CSV_SEPARATOR = ","; + + private void exportToCsv() + { + final int userSignal = fileChooser.showSaveDialog( this ); + if ( userSignal != JFileChooser.APPROVE_OPTION ) + return; + + final File file = fileChooser.getSelectedFile(); + if ( file.exists() ) + { + if ( !file.canWrite() ) + { + JOptionPane.showMessageDialog( fileChooser, "Cannot write on existing file " + file.getAbsolutePath(), "File error", JOptionPane.ERROR_MESSAGE ); + return; + } + final int doOverwrite = JOptionPane.showConfirmDialog( fileChooser, "The file already exists. Do you want to overwrite it?", "Overwrite?", JOptionPane.YES_NO_OPTION ); + if ( doOverwrite != JOptionPane.YES_OPTION ) + return; + } + + final StringBuilder sb = new StringBuilder(); + sb.append( MyTableModel.TABLE_HEADERS[ 0 ] ); + sb.append( CSV_SEPARATOR + '\t' ); + sb.append( MyTableModel.TABLE_HEADERS[ 1 ] ); + sb.append( CSV_SEPARATOR + '\t' ); + sb.append( MyTableModel.TABLE_HEADERS[ 2 ] ); + sb.append( '\n' ); + + for ( int i = 0; i < tableModel.getRowCount(); i++ ) + { + sb.append( tableModel.rows.get( i ).getName() ); + sb.append( CSV_SEPARATOR + '\t' ); + sb.append( tableModel.rows.get( i ).getTrigger().toString() ); + sb.append( CSV_SEPARATOR + '\t' ); + final List< String > contexts = tableModel.rows.get( i ).getContexts(); + if ( !contexts.isEmpty() ) + { + sb.append( contexts.get( 0 ) ); + for ( int j = 1; j < contexts.size(); j++ ) + sb.append( " - " ).append( contexts.get( j ) ); + } + sb.append( '\n' ); + } + + try ( final PrintWriter pw = new PrintWriter( file ) ) + { + pw.write( sb.toString() ); + } + catch ( final FileNotFoundException e ) + { + JOptionPane.showMessageDialog( fileChooser, "Error writing file:\n" + e.getMessage(), "Error writing file.", JOptionPane.ERROR_MESSAGE ); + e.printStackTrace(); + } + } + + private void updateEditors() + { + final int viewRow = tableBindings.getSelectedRow(); + if ( viewRow < 0 ) + { + labelCommandName.setText( "" ); + keybindingEditor.setInputTrigger( InputTrigger.NOT_MAPPED ); + contextsEditor.setTags( Collections.emptyList() ); + textAreaDescription.setText( "" ); + return; + } + + final int modelRow = tableBindings.convertRowIndexToModel( viewRow ); + final MyTableRow row = tableModel.rows.get( modelRow ); + final String action = row.getName(); + final InputTrigger trigger = row.getTrigger(); + final List< String > contexts = row.getContexts(); + + final String description; + final Set< String > acceptableContexts = commandNameToAcceptableContexts.get( action ); + if ( acceptableContexts.isEmpty() ) + { + description = ""; + } + else + { + final StringBuilder str = new StringBuilder(); + for ( final String context : acceptableContexts ) + { + final String d = actionDescriptions.get( new Command( action, context ) ); + if ( d != null ) + str.append( "\n\nIn " ).append( context ).append( ":\n" ).append( d ); + else + str.append( "\n\nIn " ).append( context ).append( " - no description." ); + } + str.delete( 0, 2 ); + description = str.toString(); + } + + labelCommandName.setText( action ); + keybindingEditor.setInputTrigger( trigger ); + contextsEditor.setAcceptableTags( acceptableContexts ); + contextsEditor.setTags( contexts ); + textAreaDescription.setText( description ); + textAreaDescription.setCaretPosition( 0 ); + + lookForConflicts(); + } + + private void unbindCommand() + { + final int viewRow = tableBindings.getSelectedRow(); + if ( viewRow < 0 ) + return; + final int modelRow = tableBindings.convertRowIndexToModel( viewRow ); + tableModel.rows.remove( modelRow ); + if ( !tableModel.addMissingRows() ) + tableModel.fireTableRowsDeleted( modelRow, modelRow ); + + // Notify listeners. + notifyListeners(); + } + + private void unbindAllCommand() + { + final int viewRow = tableBindings.getSelectedRow(); + if ( viewRow < 0 ) + return; + final int modelRow = tableBindings.convertRowIndexToModel( viewRow ); + final String removeName = tableModel.rows.get( modelRow ).getName(); + tableModel.rows.removeIf( row -> row.getName().equals( removeName ) ); + if ( !tableModel.addMissingRows() ) + tableModel.fireTableDataChanged(); + + // Notify listeners. + notifyListeners(); + } + + private void copyCommand() + { + final int viewRow = tableBindings.getSelectedRow(); + if ( viewRow < 0 ) + return; + final int modelRow = tableBindings.convertRowIndexToModel( viewRow ); + final MyTableRow row = tableModel.rows.get( modelRow ); + + final MyTableRow copiedRow = new MyTableRow( row.getName(), InputTrigger.NOT_MAPPED, row.getContexts() ); + tableModel.rows.add( modelRow + 1, copiedRow ); + blockRemoveNotMapped = true; + if ( !tableModel.mergeRows() ) + tableModel.fireTableRowsInserted( modelRow + 1, modelRow + 1 ); + + blockRemoveNotMapped = true; + // Find the row we just added if any. + final int modelRowToSelect = Collections.binarySearch( tableModel.rows, copiedRow, MyTableRowComparator ); + final int rowToSelect; + if ( modelRowToSelect < 0 ) + rowToSelect = tableBindings.convertRowIndexToView( modelRow ); + else + rowToSelect = tableBindings.convertRowIndexToView( modelRowToSelect ); + tableBindings.getSelectionModel().setSelectionInterval( rowToSelect, rowToSelect ); + + keybindingEditor.requestFocusInWindow(); + } + + private void keybindingsChanged( final InputTrigger inputTrigger ) + { + final int viewRow = tableBindings.getSelectedRow(); + if ( viewRow < 0 ) + return; + final int modelRow = tableBindings.convertRowIndexToModel( viewRow ); + final MyTableRow row = tableModel.rows.get( modelRow ); + + final MyTableRow updatedRow = new MyTableRow( row.getName(), inputTrigger, row.getContexts() ); + tableModel.rows.set( modelRow, updatedRow ); + if ( !tableModel.mergeRows() ) + tableModel.fireTableRowsUpdated( modelRow, modelRow ); + lookForConflicts(); + + final int modelRowToSelect = Collections.binarySearch( tableModel.rows, updatedRow, MyTableRowComparator ); + final int rowToSelect; + if ( modelRowToSelect < 0 ) + rowToSelect = tableBindings.convertRowIndexToView( modelRow ); + else + rowToSelect = tableBindings.convertRowIndexToView( modelRowToSelect ); + blockRemoveNotMapped = true; + tableBindings.getSelectionModel().setSelectionInterval( rowToSelect, rowToSelect ); + + // Notify listeners. + notifyListeners(); + } + + private void contextsChanged( final List< String > selectedContexts ) + { + final int viewRow = tableBindings.getSelectedRow(); + if ( viewRow < 0 ) + return; + final int modelRow = tableBindings.convertRowIndexToModel( viewRow ); + final MyTableRow row = tableModel.rows.get( modelRow ); + + final List< String > newContexts = new ArrayList<>( selectedContexts ); + newContexts.sort( null ); + tableModel.rows.set( modelRow, new MyTableRow( row.getName(), row.getTrigger(), newContexts ) ); + if ( !tableModel.addMissingRows() ) + tableModel.fireTableRowsUpdated( modelRow, modelRow ); + + // Notify listeners. + notifyListeners(); + + // Select proper row again (might have sorted differently now). + final int viewRowToSelect = tableBindings.convertRowIndexToView( modelRow ); + if ( viewRowToSelect < 0 ) + return; + tableBindings.getSelectionModel().setSelectionInterval( viewRowToSelect, viewRowToSelect ); + } + + private void notifyListeners() + { + modelChangedListeners.list.forEach( ConfigChangeListener::configChanged ); + } + + private static Map< Command, String > extractEmptyCommandDescriptions( final InputTriggerConfig keyconf ) + { + final List< InputTriggerDescription > descriptions = new InputTriggerDescriptionsBuilder( keyconf ).getDescriptions(); + final Set< Command > commands = new LinkedHashSet<>(); + for ( final InputTriggerDescription desc : descriptions ) + for( final String context : desc.getContexts() ) + commands.add( new Command( desc.getAction(), context ) ); + final Map< Command, String > commandDescriptions = new HashMap<>(); + commands.forEach( command -> commandDescriptions.put( command, null ) ); + return commandDescriptions; + } + + /** + * @deprecated Use {@code modelChangedListeners()} instead. + */ + @Deprecated + public Listeners< ConfigChangeListener > configChangeListeners() + { + return modelChangedListeners(); + } + + /** + * @deprecated Use {@code modelChangedListeners().add(listener)} instead. + */ + @Deprecated + public void addConfigChangeListener( final ConfigChangeListener listener ) + { + modelChangedListeners().add( listener ); + } + + /** + * @deprecated Use {@code modelChangedListeners().remove(listener)} instead. + */ + @Deprecated + public void removeConfigChangeListener( final ConfigChangeListener listener ) + { + modelChangedListeners().remove( listener ); + } + + /** + * Please, see the documentation of {@link VisualEditorPanel#modelChangedListeners} + * and {@link VisualEditorPanel#configCommittedListeners} to understand when these + * listeners are triggered. In short, listeners here are triggered anytime a GUI item is changed. + */ + public Listeners< ConfigChangeListener > modelChangedListeners() + { + return modelChangedListeners; + } + + /** + * Please, see the documentation of {@link VisualEditorPanel#modelChangedListeners} + * and {@link VisualEditorPanel#configCommittedListeners} to understand when these + * listeners are triggered. In short, listeners here are triggered only when "Apply" button is pressed. + */ + public Listeners< ConfigChangeListener > configCommittedListeners() + { + return configCommittedListeners; + } + + /* + * INNER CLASSES + */ + + 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() ); + final int modelRow = tableBindings.convertRowIndexToModel( row ); + final String name = tableModel.rows.get( modelRow ).getName(); + setAcceptableTags( commandNameToAcceptableContexts.get( name ) ); + + @SuppressWarnings( "unchecked" ) + final List< String > contexts = value != null + ? ( List< String > ) value + : Collections.emptyList(); + if ( contexts.isEmpty() ) + setBackground( Color.PINK ); + setTags( contexts ); + setToolTipText( contexts.toString() ); + return this; + } + } + + private static final class MyBindingsRenderer extends InputTriggerPanelEditor implements TableCellRenderer + { + + private static final long serialVersionUID = 1L; + + public MyBindingsRenderer() + { + super( 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() ); + + final InputTrigger input = ( InputTrigger ) value; + if ( null != input ) + { + setInputTrigger( input ); + final String val = input.toString(); + setToolTipText( val ); + } + else + { + setInputTrigger( InputTrigger.NOT_MAPPED ); + setToolTipText( "No binding" ); + } + return this; + } + } + + private static class MyTableRow + { + private final String name; + + private final InputTrigger trigger; + + private final List< String > contexts; + + public MyTableRow( final String name, final InputTrigger trigger, final String context ) + { + this( name, trigger, Collections.singletonList( context ) ); + } + + public MyTableRow( final String name, final InputTrigger trigger, final Collection< String > contexts ) + { + this.name = name; + this.trigger = trigger; + this.contexts = new ArrayList<>( contexts ); + } + + public String getName() + { + return name; + } + + public InputTrigger getTrigger() + { + return trigger; + } + + 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 ( !trigger.equals( that.trigger ) ) + return false; + return contexts.equals( that.contexts ); + } + + @Override + public int hashCode() + { + int result = name.hashCode(); + result = 31 * result + trigger.hashCode(); + result = 31 * result + contexts.hashCode(); + return result; + } + + @Override + public String toString() + { + return "MyTableRow{" + + "name='" + name + '\'' + + ", trigger=" + trigger + + ", contexts=" + contexts + + '}'; + } + } + + 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(); + } + + /** + * Find and remove rows with trigger NOT_MAPPED. + */ + public void removeAllNotMapped( final List< MyTableRow > rows ) + { + rows.removeIf( row -> row.getTrigger().equals( InputTrigger.NOT_MAPPED ) ); + } + + /** + * Find and remove table rows with trigger {@code NOT_MAPPED}, whose + * name and contexts are covered by other rows (that map to other + * triggers). + * + * If any changes are made, {@code fireTableDataChanged} is fired. + * + * @return true, if changes were made. + */ + public boolean removeSuperfluousNotMapped() + { + final ArrayList< MyTableRow > copy = new ArrayList<>( rows ); + removeAllNotMapped( rows ); + addMissingRows( rows ); + if ( !copy.equals( rows ) ) + { + this.fireTableDataChanged(); + return true; + } + return false; + } + + /** + * Find and merge table rows with the same action name and trigger, but + * different contexts. + * + * If any changes are made, {@code fireTableDataChanged} is fired. + * + * @return true, if changes were made. + */ + private boolean mergeRows() + { + final ArrayList< MyTableRow > copy = new ArrayList<>( rows ); + mergeRows( rows ); + if ( !copy.equals( rows ) ) + { + this.fireTableDataChanged(); + return true; + } + return false; + } + + /** + * 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; + + final Set< String > contexts = new HashSet<>(); + for ( int k = i; k < j; ++k ) + contexts.addAll( rowsUnmerged.get( k ).getContexts() ); + + rows.add( new MyTableRow( rowA.getName(), rowA.getTrigger(), contexts ) ); + + 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 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 compare( o1.trigger, o2.trigger ); + } + + private int compare( final InputTrigger o1, final InputTrigger o2 ) + { + if ( o1 == InputTrigger.NOT_MAPPED ) + return o2 == InputTrigger.NOT_MAPPED ? 0 : 1; + if ( o2 == InputTrigger.NOT_MAPPED ) + return -1; + return o1.toString().compareTo( o2.toString() ); + } + }; + + 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() ); + } + }; +} diff --git a/src/main/java/org/scijava/ui/behaviour/io/json/JsonConfigIO.java b/src/main/java/org/scijava/ui/behaviour/io/json/JsonConfigIO.java new file mode 100644 index 0000000..a8c3b7d --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/io/json/JsonConfigIO.java @@ -0,0 +1,76 @@ +/*- + * #%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.scijava.ui.behaviour.io.json; + +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.lang.reflect.Type; +import java.util.List; + +import org.scijava.ui.behaviour.io.InputTriggerDescription; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; + +public class JsonConfigIO +{ + public static void write( final List< InputTriggerDescription > descriptions, final Writer writer ) + { + final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + gson.toJson( descriptions, writer ); + } + + public static void write( final List< InputTriggerDescription > descriptions, final String fileName ) throws IOException + { + final FileWriter writer = new FileWriter( fileName ); + write( descriptions, writer ); + writer.close(); + } + + public static List< InputTriggerDescription > read( final Reader reader ) + { + final Gson gson = new GsonBuilder().create(); + final Type type = new TypeToken< List< InputTriggerDescription > >(){}.getType(); + final List< InputTriggerDescription > descriptions = gson.fromJson( reader, type ); + return descriptions; + } + + public static List< InputTriggerDescription > read( final String fileName ) throws IOException + { + final FileReader reader = new FileReader( fileName ); + final List< InputTriggerDescription > descriptions = read( reader ); + reader.close(); + return descriptions; + } +} diff --git a/src/main/java/bdv/behaviour/io/yaml/YamlConfigIO.java b/src/main/java/org/scijava/ui/behaviour/io/yaml/YamlConfigIO.java similarity index 75% rename from src/main/java/bdv/behaviour/io/yaml/YamlConfigIO.java rename to src/main/java/org/scijava/ui/behaviour/io/yaml/YamlConfigIO.java index a4cf8bd..76fb0fb 100644 --- a/src/main/java/bdv/behaviour/io/yaml/YamlConfigIO.java +++ b/src/main/java/org/scijava/ui/behaviour/io/yaml/YamlConfigIO.java @@ -1,4 +1,33 @@ -package bdv.behaviour.io.yaml; +/*- + * #%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.scijava.ui.behaviour.io.yaml; import java.io.FileReader; import java.io.FileWriter; @@ -8,7 +37,9 @@ import java.util.ArrayList; import java.util.List; +import org.scijava.ui.behaviour.io.InputTriggerDescription; import org.yaml.snakeyaml.DumperOptions; +import org.yaml.snakeyaml.LoaderOptions; import org.yaml.snakeyaml.TypeDescription; import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.constructor.Constructor; @@ -16,8 +47,6 @@ import org.yaml.snakeyaml.parser.ParserException; import org.yaml.snakeyaml.representer.Representer; -import bdv.behaviour.io.InputTriggerDescription; - /** * Facilities to serialize / de-serialize {@link InputTriggerDescription}s to a * YAML file. @@ -58,14 +87,17 @@ public class YamlConfigIO */ private static final Yaml getYaml() { - final Representer representer = new Representer(); + final DumperOptions options = new DumperOptions(); + options.setExplicitStart( true ); + + final Representer representer = new Representer( options ); representer.addClassTag( InputTriggerDescription.class, tag ); - final Constructor constructor = new Constructor(); + final LoaderOptions loaderOptions = new LoaderOptions(); + + final Constructor constructor = new Constructor( loaderOptions ); constructor.addTypeDescription( new TypeDescription( InputTriggerDescription.class, tag ) ); - final DumperOptions options = new DumperOptions(); - options.setExplicitStart( true ); final Yaml yaml = new Yaml( constructor, representer, options ); return yaml; @@ -124,7 +156,7 @@ public static void write( final Iterable< InputTriggerDescription > descriptions public static List< InputTriggerDescription > read( final Reader reader ) { final Yaml yaml = getYaml(); - final List< InputTriggerDescription > descriptions = new ArrayList< InputTriggerDescription >(); + final List< InputTriggerDescription > descriptions = new ArrayList<>(); try { diff --git a/src/main/java/org/scijava/ui/behaviour/package-info.java b/src/main/java/org/scijava/ui/behaviour/package-info.java new file mode 100644 index 0000000..139284a --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/package-info.java @@ -0,0 +1,282 @@ +/*- + * #%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% + */ +/** + *

Configurable-keys

Simplify making AWT mouse-handlers configurable. + * + * Works along the lines of the InputMap / ActionMap mechanism. The syntax for + * defining key and mouse "triggers" is described in the InputTrigger-syntax + * wiki page InputTrigger-syntax + * wiki page. They are also repeated below. + * + *

Overview

+ * + * ui-behaviour is a library for binding behaviours by keys or mouse-actions. + * The idea is similar to Swing's {@code InputMap/ActionMap} framework. The + * difference is that actions are atomic while behaviours (possibly) stretch + * over a period of time. For example, a {@code DragBehaviour} is initialized + * (e.g., with a mouse click) at certain coordinates, goes through a series of + * coordinate updates, and then ends (when the mouse is released). + *

+ * The combination of modifiers, keys, mouse buttons etc. that initiates a + * behaviour is called a "trigger" and is constructed from a string description. + *

+ * The basic syntax for a trigger description is a sequence of modifier and key + * names separated by whitespace. Examples are "{@code SPACE}", + * "{@code button1}", "{@code shift alt scroll}", and "{@code ctrl F G}". + *

+ * Additionally, one can specify a combination of modifiers, keys, mouse buttons + * etc. that should be ignored when triggering the behaviour. This is a another + * sequence of modifier and key names separated from the trigger description by + * "{@code |}". For example, "{@code ctrl button1 | shift alt}" is triggered by + * pressing the left mouse-button while holding the ctrl key. If the shift + * and/or alt key are pressed simultaneously, the trigger still matches. To + * ignore all other modifiers and keys, the special name "{@code all}" is used. + * So, "{@code A | all}" is a trigger that matches when the A key is pressed, + * regardless which other modifiers, keys, or buttons are active at the same + * time. + * + * + *

Chaining principle

+ * + * In Swing, a {@code JComponent} has an {@code InputMap}. Each InputMap can + * have a parent, and if a mapping for a given key is not found in the InputMap, + * it asks the parent. + *

+ * For BigDataViewer, we thought that this concept is nice for adding + * InputMap/ActionMap pairs with related actions to the viewer. For example one + * map with navigation shortcuts, then one map for bookmarking, and then each + * user extension could also create its own InputMap/ActionMap pair and just + * chain it to the existing maps. + *

+ * The slight complication with this is, that if you have a situation like this: + * + *

+ * component → map2 → map1
+ * 
+ * + * and you want to add another one, "map3", it should look like this: + * + *
+ * component → map3 → map2 → map1
+ * 
+ * + * So, "map3" should be inserted between "component" and "map2". + *

+ * This is resolved in ui-behaviours {@code InputActionBindings} by having a + * {@code theInputMap/theActionMap} pair that acts as an empty leaf map with + * exchangeable parents. From the component side it always looks like + * + *

+ * component → theMap
+ * 
+ * + * and then internally the {@code InputActionBindings} maintains a list + * {@code [map1, map2, ...]} which is then assembled into a new chain whenever + * something changes. So + * + *
+ * theMap → map2 → map1
+ * 
+ * + * becomes + * + *
+ * theMap → map3 → map2 → map1
+ * 
+ * + * when "map3" is added. + *

+ * And the behaviours framework doesn't have to care about the Swing particulars + * (e.g., actually each {@code JComponent} has 3 different inputmaps for + * different situations). + * + *

Blocking maps

+ * + * On top of this, InputActionBindings adds a few convenience features. + *

+ * For every map pair that you append to {@code InputActionBindings} you specify + * a unique name that can later be used to remove the map pair again. + *

+ * E.g., if map2 would have the name "bookmarking", then + * + *

+ * InputActionBindings.removeInputMap( "bookmarking" )
+ * 
+ * + * would reassemble + * + *
+ * theMap → map3 → map2 → map1
+ * 
+ * + * to + * + *
+ * theMap → map3 → map1
+ * 
+ * + * Finally, given that all maps have unique names, when you add a map you can + * specify a set of other maps to block. + *

+ * For example, let's say that you want to temporarily add your own specific + * keys that maybe conflict with the bookmarking keys. + *

+ * Starting from this situation: + * + *

+ * theMap → map3 → map2 → map1
+ * 
+ * + * the following + * + *
+ * InputActionBindings.addInputMap( map4, "myMap", "navigation", "bookmarking" )
+ * // "myMap" blocks "navigation", "bookmarking"
+ * 
+ * + * will result in + * + *
+ * theMap → map4 → map3
+ * 
+ * + * that is, map1 and map2 were blocked. + *

+ * + * However, InputActionsBindings still knows about map1 and map2. Once the + * blocking "myMap" is removed, they will be reinstated. + *

+ * As a concrete example: this whole machinery is used for bookmarking in BDV. + * You set a bookmark by pressing "shift B" and then some other key, that is the + * name for the bookmark. For example, you can press "shift B" "1" to set a + * bookmark named "1". + *

+ * After you press "shift B", a temporary inputmap is installed that blocks all + * other inputmaps. So "1" will not trigger switching to bdv source 1. After the + * bookmark name "1" is received, the temporary inputmap is removed again. + *

+ * For Behaviours, the chaining and blocking concepts are exactly the same + * + * + * + *

Modifier names.

+ * + * The following modifiers can be used: + * + * + * + *

Mouse buttons and scrolling.

+ * + * + * + * + *

Key names.

+ * + * Key names are the usual alphanumeric and function keys: + * + * + * + * Moreover the following special key names are supported (as in the + * AWTKeyStroke.getAWTKeyStroke(String) method): + * + * + * + * These are names for cursor keys: + * + * + * + * These are names for the numpad keys: + * + * + * + */ +package org.scijava.ui.behaviour; diff --git a/src/main/java/org/scijava/ui/behaviour/util/AbstractNamedAction.java b/src/main/java/org/scijava/ui/behaviour/util/AbstractNamedAction.java new file mode 100644 index 0000000..79e10a3 --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/util/AbstractNamedAction.java @@ -0,0 +1,53 @@ +/*- + * #%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.scijava.ui.behaviour.util; + +import javax.swing.AbstractAction; +import javax.swing.ActionMap; + +public abstract class AbstractNamedAction extends AbstractAction +{ + public AbstractNamedAction( final String name ) + { + super( name ); + } + + public String name() + { + return ( String ) getValue( NAME ); + } + + public void put( final ActionMap map ) + { + map.put( name(), this ); + } + + private static final long serialVersionUID = 1L; +} diff --git a/src/main/java/org/scijava/ui/behaviour/util/AbstractNamedBehaviour.java b/src/main/java/org/scijava/ui/behaviour/util/AbstractNamedBehaviour.java new file mode 100644 index 0000000..2034217 --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/util/AbstractNamedBehaviour.java @@ -0,0 +1,53 @@ +/*- + * #%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.scijava.ui.behaviour.util; + +import org.scijava.ui.behaviour.Behaviour; +import org.scijava.ui.behaviour.BehaviourMap; + +public abstract class AbstractNamedBehaviour implements Behaviour +{ + private final String name; + + public AbstractNamedBehaviour( final String name ) + { + this.name = name; + } + + public String name() + { + return name; + } + + public void put( final BehaviourMap map ) + { + map.put( name(), this ); + } +} diff --git a/src/main/java/org/scijava/ui/behaviour/util/Actions.java b/src/main/java/org/scijava/ui/behaviour/util/Actions.java new file mode 100644 index 0000000..4cb4a68 --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/util/Actions.java @@ -0,0 +1,245 @@ +/*- + * #%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.scijava.ui.behaviour.util; + +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 javax.swing.ActionMap; +import javax.swing.InputMap; +import javax.swing.KeyStroke; + +import org.scijava.ui.behaviour.KeyStrokeAdder; +import org.scijava.ui.behaviour.io.InputTriggerConfig; + +/** + * Convenience class for adding to a {@link InputMap}/{@link ActionMap} pair, + * with {@link InputMap} keys that are read from a given + * {@link InputTriggerConfig}. + * + * The maps can be {@link #install(InputActionBindings, String) installed} into + * {@link InputActionBindings}. + * + * @author Tobias Pietzsch + */ +public class Actions +{ + private final InputMap inputMap; + + private final ActionMap actionMap; + + private final String[] keyConfigContexts; + + protected KeyStrokeAdder.Factory keyConfig; + + protected KeyStrokeAdder keyStrokeAdder; + + /** + * Construct with new, empty {@link InputMap} and {@link ActionMap}. Actions + * that are added to these maps (using + * {@link #namedAction(AbstractNamedAction, String...)}, + * {@link #runnableAction(Runnable, String, String...)}) have their key + * stroke triggers defined by the specified {@code keyConfig}. + * + * @param keyConfig + * added actions have their key stroke triggers defined by the + * specified {@code keyConfig}. (overrides default key strokes.) + * @param keyConfigContexts + * for which context names in the keyConfig should key strokes be + * retrieved. + */ + public Actions( + final KeyStrokeAdder.Factory keyConfig, + final String ... keyConfigContexts ) + { + this( new InputMap(), new ActionMap(), keyConfig, keyConfigContexts ); + } + + /** + * Construct with specified {@link InputMap} and {@link ActionMap}. Actions + * that are added to these maps (using + * {@link #namedAction(AbstractNamedAction, String...)}, + * {@link #runnableAction(Runnable, String, String...)}) have their key + * stroke triggers defined by the specified {@code keyConfig}. + * + * @param inputMap + * {@link InputMap} to add to. + * @param actionMap + * {@link ActionMap} to add to. + * @param keyConfig + * added actions have their key stroke triggers defined by the + * specified {@code keyConfig}. (overrides default key strokes.) + * @param keyConfigContexts + * for which context names in the keyConfig should key strokes be + * retrieved. + */ + public Actions( + final InputMap inputMap, + final ActionMap actionMap, + final KeyStrokeAdder.Factory keyConfig, + final String ... keyConfigContexts ) + { + this.actionMap = actionMap; + this.inputMap = inputMap; + this.keyConfig = keyConfig != null ? keyConfig : new InputTriggerConfig(); + this.keyConfigContexts = keyConfigContexts; + keyStrokeAdder = this.keyConfig.keyStrokeAdder( inputMap, keyConfigContexts ); + } + + public InputMap getInputMap() + { + return inputMap; + } + + public ActionMap getActionMap() + { + return actionMap; + } + + /** + * Adds the {@link InputMap} and {@link ActionMap} to + * {@code inputActionBindings} under the given {@code name}. + *

+ * Convenience method for + * + *

+	 * inputActionBindings.addActionMap( name, getActionMap() );
+	 * inputActionBindings.addInputMap( name, getInputMap() );
+	 * 
+ * + * @param inputActionBindings + * where to install the {@link InputMap}/{@link ActionMap}. + * @param name + * name under which the {@link InputMap}/{@link ActionMap} is + * installed. + */ + public void install( + final InputActionBindings inputActionBindings, + final String name ) + { + inputActionBindings.addActionMap( name, actionMap ); + inputActionBindings.addInputMap( name, inputMap ); + } + + /** + * Create and install a new {@link javax.swing.Action} with the specified + * {@code name} that calls the specified {@link Runnable} when triggered. + * + * @param runnable + * action to install. + * @param name + * name of the action. + * @param defaultKeyStrokes + * default key strokes that trigger the action. These are used, + * if no mapping in the {@link InputTriggerConfig} (specified in + * the constructor) is found. + */ + public void runnableAction( final Runnable runnable, final String name, final String... defaultKeyStrokes ) + { + namedAction( new RunnableAction( name, runnable ), defaultKeyStrokes ); + } + + /** + * Install the specified {@link AbstractNamedAction}. + * + * @param action + * action to install. + * @param defaultKeyStrokes + * default key strokes that trigger the action. These are used, + * if no mapping in the {@link InputTriggerConfig} (specified in + * the constructor) is found. + */ + public void namedAction( final AbstractNamedAction action, final String... defaultKeyStrokes ) + { + keyStrokeAdder.put( action.name(), defaultKeyStrokes ); + action.put( actionMap ); + } + + /** + * Clears the {@link InputMap} and re-adds all ({@code String}) action keys + * from {@link ActionMap} using the provided {@code keyConfig}. + * + * @param keyConfig + * the new keyConfig + */ + public void updateKeyConfig( final InputTriggerConfig keyConfig ) + { + updateKeyConfig( keyConfig, true ); + } + + /** + * Clears the {@link InputMap} and re-adds all ({@code String}) action keys + * from {@link ActionMap} using the provided {@code keyConfig}. + *

+ * Actions that are currently in the {@code InputMap} but are not defined in + * the {@code keyConfig} retain their current keystrokes (note that + * {@code keyConfig} can map actions to "not mapped"). + * + * @param keyConfig + * the new keyConfig + * @param clearAll + * whether to clear all bindings (also of actions that are + * undefined in {@code keyConfig}) + */ + public void updateKeyConfig( final InputTriggerConfig keyConfig, final boolean clearAll ) + { + this.keyConfig = keyConfig; + + final Map< Object, List< KeyStroke > > unassigned = new HashMap<>(); + if ( !clearAll ) + { + final KeyStroke[] inputs = inputMap.keys(); + if ( inputs != null ) + { + final HashSet< String > contexts = new HashSet<>( Arrays.asList( keyConfigContexts ) ); + for ( final KeyStroke input : inputs ) + { + final Object actionKey = inputMap.get( input ); + if ( ( !( actionKey instanceof String ) ) || keyConfig.getInputs( ( String ) actionKey, contexts ).isEmpty() ) + unassigned.computeIfAbsent( actionKey, k -> new ArrayList<>() ).add( input ); + } + } + } + + keyStrokeAdder = keyConfig.keyStrokeAdder( inputMap, keyConfigContexts ); + inputMap.clear(); + final Object[] keys = actionMap.keys(); + if ( keys != null ) + for ( final Object o : keys ) + if ( !unassigned.containsKey( o ) ) + keyStrokeAdder.put( ( String ) o ); + + unassigned.forEach( ( actionMapKey, keyStrokes ) -> keyStrokes.forEach( keyStroke -> inputMap.put( keyStroke, actionMapKey ) ) ); + } +} diff --git a/src/main/java/org/scijava/ui/behaviour/util/Behaviours.java b/src/main/java/org/scijava/ui/behaviour/util/Behaviours.java new file mode 100644 index 0000000..0774627 --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/util/Behaviours.java @@ -0,0 +1,224 @@ +/*- + * #%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.scijava.ui.behaviour.util; + +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.Map.Entry; +import java.util.Set; + +import org.scijava.ui.behaviour.Behaviour; +import org.scijava.ui.behaviour.BehaviourMap; +import org.scijava.ui.behaviour.InputTrigger; +import org.scijava.ui.behaviour.InputTriggerAdder; +import org.scijava.ui.behaviour.InputTriggerMap; +import org.scijava.ui.behaviour.io.InputTriggerConfig; + +/** + * Convenience class for adding to a {@link InputTriggerMap}/{@link BehaviourMap} pair, + * with {@link InputTriggerMap} triggers that are read from a given + * {@link InputTriggerConfig}. + * + * The maps can be {@link #install(TriggerBehaviourBindings, String) installed} into + * {@link TriggerBehaviourBindings}. + * + * @author Tobias Pietzsch + */ +public class Behaviours +{ + private final InputTriggerMap inputTriggerMap; + + private final BehaviourMap behaviourMap; + + private final String[] keyConfigContexts; + + protected InputTriggerAdder.Factory keyConfig; + + protected InputTriggerAdder inputTriggerAdder; + + /** + * Construct with new, empty {@link InputTriggerMap} and + * {@link BehaviourMap}. Behaviours that are added to these maps (using + * {@link #behaviour(Behaviour, String, String...)}, + * {@link #namedBehaviour(AbstractNamedBehaviour, String...)}) have their + * triggers defined by the specified {@code keyConfig}. + * + * @param keyConfig + * added behaviours have their triggers defined by the specified + * {@code keyConfig}. (overrides default triggers.) + * @param keyConfigContexts + * for which context names in the keyConfig should triggers be + * retrieved. + */ + public Behaviours( + final InputTriggerAdder.Factory keyConfig, + final String ... keyConfigContexts ) + { + this( new InputTriggerMap(), new BehaviourMap(), keyConfig, keyConfigContexts ); + } + + /** + * Construct with the specified {@link InputTriggerMap} and + * {@link BehaviourMap}. Behaviours that are added to these maps (using + * {@link #behaviour(Behaviour, String, String...)}, + * {@link #namedBehaviour(AbstractNamedBehaviour, String...)}) have their + * triggers defined by the specified {@code keyConfig}. + * + * @param inputTriggerMap + * {@link InputTriggerMap} to add to. + * @param behaviourMap + * {@link BehaviourMap} to add to. + * @param keyConfig + * added behaviours have their triggers defined by the specified + * {@code keyConfig}. (overrides default triggers.) + * @param keyConfigContexts + * for which context names in the keyConfig should triggers be + * retrieved. + */ + public Behaviours( + final InputTriggerMap inputTriggerMap, + final BehaviourMap behaviourMap, + final InputTriggerAdder.Factory keyConfig, + final String ... keyConfigContexts ) + { + this.inputTriggerMap = inputTriggerMap; + this.behaviourMap = behaviourMap; + this.keyConfig = keyConfig != null ? keyConfig : new InputTriggerConfig(); + this.keyConfigContexts = keyConfigContexts; + inputTriggerAdder = this.keyConfig.inputTriggerAdder( inputTriggerMap, keyConfigContexts ); + } + + public InputTriggerMap getInputTriggerMap() + { + return inputTriggerMap; + } + + public BehaviourMap getBehaviourMap() + { + return behaviourMap; + } + + /** + * Adds the {@link InputTriggerMap} and {@link BehaviourMap} to + * {@code triggerBehaviourBindings} under the given {@code name}. + *

+ * Convenience method for + * + *

+		triggerBehaviourBindings.addInputTriggerMap( name, getInputTriggerMap() );
+		triggerBehaviourBindings.addBehaviourMap( name, getBehaviourMap() );
+	 * 
+ * + * @param triggerBehaviourBindings + * where to install the {@link InputTriggerMap}/ + * {@link BehaviourMap}. + * @param name + * name under which the {@link InputTriggerMap}/ + * {@link BehaviourMap} is installed. + */ + public void install( + final TriggerBehaviourBindings triggerBehaviourBindings, + final String name ) + { + triggerBehaviourBindings.addInputTriggerMap( name, inputTriggerMap ); + triggerBehaviourBindings.addBehaviourMap( name, behaviourMap ); + } + + public void behaviour( final Behaviour behaviour, final String name, final String... defaultTriggers ) + { + inputTriggerAdder.put( name, defaultTriggers ); + behaviourMap.put( name, behaviour ); + } + + public void namedBehaviour( final AbstractNamedBehaviour behaviour, final String... defaultTriggers ) + { + inputTriggerAdder.put( behaviour.name(), defaultTriggers ); + behaviour.put( behaviourMap ); + } + + /** + * Clears the {@link InputTriggerMap} and re-adds all behaviour keys from + * {@link BehaviourMap} using the provided {@code keyConfig}. + * + * @param keyConfig + * the new keyConfig + */ + public void updateKeyConfig( final InputTriggerConfig keyConfig ) + { + updateKeyConfig( keyConfig, true ); + } + + /** + * Clears the {@link InputTriggerMap} and re-adds all behaviour keys from + * {@link BehaviourMap} using the provided {@code keyConfig}. + *

+ * If {@code clearAll==false}, then behaviours that are currently in the + * {@code InputTriggerMap} but are not defined in the {@code keyConfig} + * retain their current keystrokes (note that {@code keyConfig} can map + * behaviours to "not mapped"). + * + * @param keyConfig + * the new keyConfig + * @param clearAll + * whether to clear all bindings (also of behaviours that are + * undefined in {@code keyConfig}) + */ + public void updateKeyConfig( final InputTriggerConfig keyConfig, final boolean clearAll ) + { + this.keyConfig = keyConfig; + + final Map< String, List< InputTrigger > > unassigned = new HashMap<>(); + if ( !clearAll ) + { + final Map< InputTrigger, Set< String > > bindings = inputTriggerMap.getBindings(); + final HashSet< String > contexts = new HashSet<>( Arrays.asList( keyConfigContexts ) ); + for ( final Entry< InputTrigger, Set< String > > entry : bindings.entrySet() ) + { + final InputTrigger trigger = entry.getKey(); + for ( final String behaviourKey : entry.getValue() ) + { + if ( keyConfig.getInputs( behaviourKey, contexts ).isEmpty() ) + unassigned.computeIfAbsent( behaviourKey, k -> new ArrayList<>() ).add( trigger ); + } + } + } + + inputTriggerAdder = keyConfig.inputTriggerAdder( inputTriggerMap, keyConfigContexts ); + inputTriggerMap.clear(); + for ( final String behaviourName : behaviourMap.keys() ) + inputTriggerAdder.put( behaviourName ); + + unassigned.forEach( ( behaviourKey, triggers ) -> triggers.forEach( trigger -> inputTriggerMap.put( trigger, behaviourKey ) ) ); + } +} diff --git a/src/main/java/org/scijava/ui/behaviour/util/InputActionBindings.java b/src/main/java/org/scijava/ui/behaviour/util/InputActionBindings.java new file mode 100644 index 0000000..3a133ec --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/util/InputActionBindings.java @@ -0,0 +1,440 @@ +/*- + * #%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.scijava.ui.behaviour.util; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.ListIterator; +import java.util.Set; + +import javax.swing.ActionMap; +import javax.swing.ComponentInputMap; +import javax.swing.InputMap; +import javax.swing.JComponent; +import javax.swing.SwingUtilities; + +/** + * Maintains lists of {@link ActionMap}s and {@link InputMap}s, which are + * chained to a {@link #getConcatenatedInputMap() concatenated InputMap} and a + * {@link #getConcatenatedActionMap() concatenated ActionMap}. Maps can be added + * and will be chained in reverse order of addition, that is, the last added map + * overrides all previous ones. For added {@link InputMap}s it is possible to + * block maps that were added earlier. + * + * @author Tobias Pietzsch + */ +public final class InputActionBindings +{ + /** + * the leaf of the {@link InputMap} chain. + */ + private final InputMap theInputMap; + + /** + * the leaf of the {@link ActionMap} chain. + */ + private final ActionMap theActionMap; + + /** + * parent for the {@link #getConcatenatedInputMap() concatenated InputMap}. + */ + private InputMap parentInputMap; + + /** + * parent for the {@link #getConcatenatedActionMap()} () concatenated ActionMap}. + */ + private ActionMap parentActionMap; + + private final List< Actions > actions; + + private final List< Keys > inputs; + + /** + * Create empty leaf {@link InputMap} and {@link ActionMap}. + */ + public InputActionBindings() + { + this( new InputMap() ); + } + + /** + * Create empty leaf {@link ActionMap} and set the given leaf + * {@link InputMap}. + *

+ * This constructor is intended for use with + * {@link JComponent#WHEN_IN_FOCUSED_WINDOW}. In this case, a + * {@link ComponentInputMap} for the correct {@link JComponent} is required. + * + * @param leafInputMap + * the leaf {@link InputMap}. + */ + public InputActionBindings( final InputMap leafInputMap ) + { + theInputMap = leafInputMap; + theActionMap = new ActionMap(); + actions = new ArrayList<>(); + inputs = new ArrayList<>(); + } + + /** + * Add as {@link ActionMap} with the specified id to the end of the list + * (overrides maps that were added earlier). If the specified id already + * exists in the list, remove the corresponding earlier {@link ActionMap}. + */ + public void addActionMap( final String id, final ActionMap actionMap ) + { + removeId( actions, id ); + if ( actionMap != null ) + actions.add( new Actions( id, actionMap ) ); + updateTheActionMap(); + } + + /** + * Add as {@link ActionMap} with the specified id at the specified position + * in the list (overrides maps at lower positions). If the specified id + * already exists in the list, remove the corresponding earlier + * {@link ActionMap}. + */ + public void addActionMap( final int index, final String id, final ActionMap actionMap ) + { + removeId( actions, id ); + if ( actionMap != null ) + { + final int i = Math.max( 0, Math.min( actions.size(), index ) ); + actions.add( i, new Actions( id, actionMap ) ); + } + updateTheActionMap(); + } + + /** + * Remove the {@link ActionMap} with the given id from the list. + */ + public void removeActionMap( final String id ) + { + if ( removeId( actions, id ) ) + updateTheActionMap(); + } + + /** + * Set existing parent ActionMap, which is managed outside of this + * InputActionBindings and serves as a parent for the whole chain. + */ + public void setParentActionMap( final ActionMap parent ) + { + parentActionMap = parent; + updateTheActionMap(); + } + + /** + * Adds a {@link InputMap} with the specified id to the end of the list + * (overrides maps that were added earlier). If the specified id already + * exists in the list, remove the corresponding earlier {@link InputMap}. + *

+ * If {@code idsToBlock} are given, {@link InputMap}s with these ids earlier + * in the chain that should be disabled. The special id "all" blocks all + * earlier {@link InputMap}s. + * + * @param id + * @param inputMap + * @param idsToBlock + * ids of {@link InputMap}s earlier in the chain that should be + * disabled. + */ + public void addInputMap( final String id, final InputMap inputMap, final String... idsToBlock ) + { + addInputMap( id, inputMap, Arrays.asList( idsToBlock ) ); + } + + /** + * Adds a {@link InputMap} with the specified id to the end of the list + * (overrides maps that were added earlier). If the specified id already + * exists in the list, remove the corresponding earlier {@link InputMap}. + *

+ * If {@code idsToBlock} are given, {@link InputMap}s with these ids earlier + * in the chain that should be disabled. The special id "all" blocks all + * earlier {@link InputMap}s. + * + * @param id + * @param inputMap + * @param idsToBlock + * ids of {@link InputMap}s earlier in the chain that should be + * disabled. + */ + public void addInputMap( final String id, final InputMap inputMap, final Collection< String > idsToBlock ) + { + removeId( inputs, id ); + if ( inputMap != null ) + inputs.add( new Keys( id, inputMap, idsToBlock ) ); + updateTheInputMap(); + } + + /** + * Inserts a {@link InputMap} with the specified id at the specified + * position in the list (overrides maps at lower positions). If the + * specified id already exists in the list, remove the corresponding earlier + * {@link InputMap}. + *

+ * If {@code idsToBlock} are given, {@link InputMap}s with these ids earlier + * in the chain that should be disabled. The special id "all" blocks all + * earlier {@link InputMap}s. + * + * @param index + * @param id + * @param inputMap + * @param idsToBlock + * ids of {@link InputMap}s earlier in the chain that should be + * disabled. + */ + public void addInputMap( final int index, final String id, final InputMap inputMap, final String... idsToBlock ) + { + addInputMap( index, id, inputMap, Arrays.asList( idsToBlock ) ); + } + + /** + * Inserts a {@link InputMap} with the specified id at the specified + * position in the list (overrides maps at lower positions). If the + * specified id already exists in the list, remove the corresponding earlier + * {@link InputMap}. + *

+ * If {@code idsToBlock} are given, {@link InputMap}s with these ids earlier + * in the chain that should be disabled. The special id "all" blocks all + * earlier {@link InputMap}s. + * + * @param index + * @param id + * @param inputMap + * @param idsToBlock + * ids of {@link InputMap}s earlier in the chain that should be + * disabled. + */ + public void addInputMap( final int index, final String id, final InputMap inputMap, final Collection< String > idsToBlock ) + { + removeId( inputs, id ); + if ( inputMap != null ) + { + final int i = Math.max( 0, Math.min( inputs.size(), index ) ); + inputs.add( i, new Keys( id, inputMap, idsToBlock ) ); + } + updateTheInputMap(); + } + + /** + * Remove the {@link InputMap} with the given id from the list. + */ + public void removeInputMap( final String id ) + { + if ( removeId( inputs, id ) ) + updateTheInputMap(); + } + + /** + * Set existing parent InputMap, which is managed outside of this + * InputActionBindings and serves as a parent for the whole chain. + */ + public void setParentInputMap( final InputMap parent ) + { + parentInputMap = parent; + updateTheInputMap(); + } + + /** + * Get the chained {@link InputMap}. This is the leaf map, that has all + * {@link #addInputMap(String, InputMap, String...) added} {@link InputMap}s + * as parents. Note, that this will remain the same instance when maps are + * added or removed. + */ + public InputMap getConcatenatedInputMap() + { + return theInputMap; + } + + /** + * Get the chained {@link ActionMap}. This is the leaf map, that has all + * {@link #addActionMap(String, ActionMap) added} {@link ActionMap}s as + * parents. Note, that this will remain the same instance when maps are + * added or removed. + */ + public ActionMap getConcatenatedActionMap() + { + return theActionMap; + } + + /** + * Creates a new {@code InputActionBindings} and installs it into + * {@code component}, either augmenting or replacing {@code component}s + * existing {@code InputMap} and {@code ActionMap}. + * + * @param component + * the component whose {@code InputMap} and {@code ActionMap} to set. + * @param condition + * one of {@code JComponent.WHEN_IN_FOCUSED_WINDOW}, + * {@code WHEN_IN_FOCUSED_WINDOW}, + * {@code WHEN_ANCESTOR_OF_FOCUSED_COMPONENT}. + * @param replaceExistingMaps + * if {@code true}, the existing {@code InputMap} and {@code ActionMap} + * will be replaced. If {@code false}, the existing maps will become the + * parents of the new ones. + * @return the new {@code InputActionBindings} + */ + public static InputActionBindings installNewBindings( final JComponent component, final int condition, final boolean replaceExistingMaps ) + { + final InputActionBindings keybindings = new InputActionBindings(); + if ( !replaceExistingMaps ) + { + final ActionMap existingActionMap = component.getActionMap(); + final InputMap existingInputMap = component.getInputMap( condition ); + keybindings.setParentActionMap( existingActionMap.getParent() ); + keybindings.setParentInputMap( existingInputMap.getParent() ); + } + SwingUtilities.replaceUIActionMap( component, keybindings.getConcatenatedActionMap() ); + SwingUtilities.replaceUIInputMap( component, condition, keybindings.getConcatenatedInputMap() ); + return keybindings; + } + + private interface WithId + { + public String getId(); + } + + private static class Actions implements WithId + { + private final String id; + + private final ActionMap actionMap; + + public Actions( final String id, final ActionMap actionMap ) + { + this.id = id; + this.actionMap = actionMap; + } + + @Override + public String getId() + { + return id; + } + + public ActionMap getActionMap() + { + return actionMap; + } + } + + private static class Keys implements WithId + { + private final String id; + + private final InputMap inputMap; + + private final HashSet< String > idsToBlock; + + public Keys( final String id, final InputMap inputMap, final Collection< String > idsToBlock ) + { + this.id = id; + this.inputMap = inputMap; + this.idsToBlock = new HashSet<>( idsToBlock ); + } + + @Override + public String getId() + { + return id; + } + + public InputMap getInputMap() + { + return inputMap; + } + + public Set< String > getKeysIdsToBlock() + { + return idsToBlock; + } + } + + private static boolean removeId( final List< ? extends WithId > list, final String id ) + { + for ( int i = 0; i < list.size(); ++i ) + if ( list.get( i ).getId().equals( id ) ) + { + list.remove( i ); + return true; + } + return false; + } + + private void updateTheActionMap() + { + final ListIterator< Actions > iter = actions.listIterator( actions.size() ); + ActionMap root = theActionMap; + while ( iter.hasPrevious() ) + { + final ActionMap map = iter.previous().getActionMap(); + if ( map != null ) + { + root.setParent( map ); + root = map; + } + } + root.setParent( parentActionMap ); + } + + private void updateTheInputMap() + { + final ListIterator< Keys > iter = inputs.listIterator( inputs.size() ); + InputMap root = theInputMap; + final HashSet< String > blocked = new HashSet<>(); + while ( iter.hasPrevious() ) + { + final Keys keys = iter.previous(); + + if ( blocked.contains( keys.getId() ) ) + continue; + + final InputMap map = keys.getInputMap(); + if ( map != null ) + { + root.setParent( map ); + root = map; + } + + blocked.addAll( keys.getKeysIdsToBlock() ); + if ( blocked.contains( "all" ) ) + { + root.setParent( null ); + return; + } + } + root.setParent( parentInputMap ); + } +} diff --git a/src/main/java/org/scijava/ui/behaviour/util/RunnableAction.java b/src/main/java/org/scijava/ui/behaviour/util/RunnableAction.java new file mode 100644 index 0000000..1c74d23 --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/util/RunnableAction.java @@ -0,0 +1,57 @@ +/*- + * #%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.scijava.ui.behaviour.util; + +import java.awt.event.ActionEvent; + +public class RunnableAction extends AbstractNamedAction implements Runnable +{ + private final Runnable action; + + public RunnableAction( final String name, final Runnable action ) + { + super( name ); + this.action = action; + } + + @Override + public void actionPerformed( final ActionEvent e ) + { + action.run(); + } + + @Override + public void run() + { + action.run(); + } + + private static final long serialVersionUID = 1L; +} diff --git a/src/main/java/org/scijava/ui/behaviour/util/TriggerBehaviourBindings.java b/src/main/java/org/scijava/ui/behaviour/util/TriggerBehaviourBindings.java new file mode 100644 index 0000000..a9cce8e --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/util/TriggerBehaviourBindings.java @@ -0,0 +1,290 @@ +/*- + * #%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.scijava.ui.behaviour.util; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.ListIterator; +import java.util.Set; + +import org.scijava.ui.behaviour.BehaviourMap; +import org.scijava.ui.behaviour.InputTriggerMap; + +/** + * Maintains lists of {@link BehaviourMap}s and {@link InputTriggerMap}s, which + * are chained to a {@link #getConcatenatedInputTriggerMap() concatenated + * InputTriggerMap} and a {@link #getConcatenatedBehaviourMap() concatenated + * BehaviourMap}. Maps can be added and will be chained in reverse order of + * addition, that is, the last added map overrides all previous ones. For + * {@link InputTriggerMap}s it is possible to block maps that were added + * earlier. + * + * @author Tobias Pietzsch <tobias.pietzsch@gmail.com> + */ +public final class TriggerBehaviourBindings +{ + /** + * the root of the {@link InputTriggerMap} chain. + */ + private final InputTriggerMap theInputTriggerMap; + + /** + * the root of the {@link BehaviourMap} chain. + */ + private final BehaviourMap theBehaviourMap; + + /** + * Create chained maps just consisting of empty roots. + */ + public TriggerBehaviourBindings() + { + theInputTriggerMap = new InputTriggerMap(); + theBehaviourMap = new BehaviourMap(); + behaviours = new ArrayList<>(); + triggers= new ArrayList<>(); + } + + /** + * Add as {@link BehaviourMap} with the specified id to the end of the list + * (overrides maps that were added earlier). If the specified id already + * exists in the list, remove the corresponding earlier {@link BehaviourMap}. + */ + public void addBehaviourMap( final String id, final BehaviourMap behaviourMap ) + { + removeId( behaviours, id ); + if ( behaviourMap != null ) + behaviours.add( new Behaviours( id, behaviourMap ) ); + updateTheBehaviourMap(); + } + + /** + * Remove the {@link BehaviourMap} with the given id from the list. + */ + public void removeBehaviourMap( final String id ) + { + if ( removeId( behaviours, id ) ) + updateTheBehaviourMap(); + } + + /** + * Add as {@link InputTriggerMap} with the specified id to the end of the + * list (overrides maps that were added earlier). If the specified id + * already exists in the list, remove the corresponding earlier + * {@link InputTriggerMap}. + *

+ * If {@code idsToBlock} are given, {@link InputTriggerMap}s with these ids + * earlier in the chain that should be disabled. The special id "all" blocks + * all earlier {@link InputTriggerMap}s. + * + * @param id + * @param inputTriggerMap + * @param idsToBlock + * ids of {@link InputTriggerMap}s earlier in the chain that + * should be disabled. + */ + public void addInputTriggerMap( final String id, final InputTriggerMap inputTriggerMap, final String... idsToBlock ) + { + addInputTriggerMap( id, inputTriggerMap, Arrays.asList( idsToBlock ) ); + } + + /** + * Add as {@link InputTriggerMap} with the specified id to the end of the + * list (overrides maps that were added earlier). If the specified id + * already exists in the list, remove the corresponding earlier + * {@link InputTriggerMap}. + *

+ * If {@code idsToBlock} are given, {@link InputTriggerMap}s with these ids + * earlier in the chain that should be disabled. The special id "all" blocks + * all earlier {@link InputTriggerMap}s. + * + * @param id + * @param inputTriggerMap + * @param idsToBlock + * ids of {@link InputTriggerMap}s earlier in the chain that + * should be disabled. + */ + public void addInputTriggerMap( final String id, final InputTriggerMap inputTriggerMap, final Collection< String > idsToBlock ) + { + removeId( triggers, id ); + if ( inputTriggerMap != null ) + triggers.add( new Triggers( id, inputTriggerMap, idsToBlock ) ); + updateTheInputTriggerMap(); + } + + /** + * Remove the {@link InputTriggerMap} with the given id from the list. + */ + public void removeInputTriggerMap( final String id ) + { + if ( removeId( triggers, id ) ) + updateTheInputTriggerMap(); + } + + /** + * Get the chained {@link InputTriggerMap}. Note, that this will remain the + * same instance when maps are added or removed. + */ + public InputTriggerMap getConcatenatedInputTriggerMap() + { + return theInputTriggerMap; + } + + /** + * Get the chained {@link BehaviourMap}. Note, that this will remain the + * same instance when maps are added or removed. + */ + public BehaviourMap getConcatenatedBehaviourMap() + { + return theBehaviourMap; + } + + private interface WithId + { + public String getId(); + } + + private static class Behaviours implements WithId + { + private final String id; + + private final BehaviourMap behaviourMap; + + public Behaviours( final String id, final BehaviourMap behaviourMap ) + { + this.id = id; + this.behaviourMap = behaviourMap; + } + + @Override + public String getId() + { + return id; + } + + public BehaviourMap getBehaviourMap() + { + return behaviourMap; + } + } + + private static class Triggers implements WithId + { + private final String id; + + private final InputTriggerMap inputTriggerMap; + + private final HashSet< String > idsToBlock; + + public Triggers( final String id, final InputTriggerMap inputTriggerMap, final Collection< String > idsToBlock ) + { + this.id = id; + this.inputTriggerMap = inputTriggerMap; + this.idsToBlock = new HashSet<>( idsToBlock ); + } + + @Override + public String getId() + { + return id; + } + + public InputTriggerMap getInputTriggerMap() + { + return inputTriggerMap; + } + + public Set< String > getKeysIdsToBlock() + { + return idsToBlock; + } + } + + private final List< Behaviours > behaviours; + + private final List< Triggers > triggers; + + private static boolean removeId( final List< ? extends WithId > list, final String id ) + { + for ( int i = 0; i < list.size(); ++i ) + if ( list.get( i ).getId().equals( id ) ) + { + list.remove( i ); + return true; + } + return false; + } + + private void updateTheBehaviourMap() + { + final ListIterator< Behaviours > iter = behaviours.listIterator( behaviours.size() ); + BehaviourMap root = theBehaviourMap; + while ( iter.hasPrevious() ) + { + final BehaviourMap map = iter.previous().getBehaviourMap(); + if ( map != null ) + { + map.setParent( null ); + root.setParent( map ); + root = map; + } + } + root.setParent( null ); + } + + private void updateTheInputTriggerMap() + { + final ListIterator< Triggers > iter = triggers.listIterator( triggers.size() ); + InputTriggerMap root = theInputTriggerMap; + final HashSet< String > blocked = new HashSet<>(); + while ( iter.hasPrevious() ) + { + final Triggers keys = iter.previous(); + + if ( blocked.contains( keys.getId() ) ) + continue; + + final InputTriggerMap map = keys.getInputTriggerMap(); + if ( map != null ) + { + map.setParent( null ); + root.setParent( map ); + root = map; + } + + blocked.addAll( keys.getKeysIdsToBlock() ); + if ( blocked.contains( "all" ) ) + break; + } + root.setParent( null ); + } +} diff --git a/src/main/java/org/scijava/ui/behaviour/util/WrappedActionMap.java b/src/main/java/org/scijava/ui/behaviour/util/WrappedActionMap.java new file mode 100644 index 0000000..9929f3a --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/util/WrappedActionMap.java @@ -0,0 +1,106 @@ +/*- + * #%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.scijava.ui.behaviour.util; + +import javax.swing.Action; +import javax.swing.ActionMap; + +/** + * A view of the specified {@link ActionMap} that can have its own parent. The + * wrapped {@code ActionMap} should not have a parent!? + */ +public class WrappedActionMap extends ActionMap +{ + private static final long serialVersionUID = 1L; + + private final ActionMap actionMap; + + private ActionMap parent; + + public WrappedActionMap( final ActionMap inputMap ) + { + this.actionMap = inputMap; + } + + @Override + public void setParent( final ActionMap map ) + { + parent = map; + } + + @Override + public ActionMap getParent() + { + return parent; + } + + @Override + public void put( final Object key, final Action action ) + { + throw new UnsupportedOperationException( getClass().getSimpleName() + " cannot be modified." ); + } + + @Override + public Action get( final Object key ) + { + final Action value = actionMap.get( key ); + if ( value == null ) + { + final ActionMap parent = getParent(); + if ( parent != null ) + return parent.get( key ); + } + return value; + } + + @Override + public void remove( final Object key ) + { + throw new UnsupportedOperationException( getClass().getSimpleName() + " cannot be modified." ); + } + + @Override + public void clear() + { + throw new UnsupportedOperationException( getClass().getSimpleName() + " cannot be modified." ); + } + + @Override + public Object[] keys() + { + return actionMap.keys(); + } + + @Override + public int size() + { + return actionMap.size(); + } +} diff --git a/src/main/java/org/scijava/ui/behaviour/util/WrappedBehaviourMap.java b/src/main/java/org/scijava/ui/behaviour/util/WrappedBehaviourMap.java new file mode 100644 index 0000000..6e34af0 --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/util/WrappedBehaviourMap.java @@ -0,0 +1,125 @@ +/*- + * #%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.scijava.ui.behaviour.util; + +import java.util.HashMap; +import java.util.Map; + +import org.scijava.ui.behaviour.Behaviour; +import org.scijava.ui.behaviour.BehaviourMap; + +/** + * A view of the specified {@link BehaviourMap} that can have its own parent. + * The wrapped {@code BehaviourMap} should not have a parent!? + */ +public class WrappedBehaviourMap extends BehaviourMap +{ + private final BehaviourMap behaviourMap; + + private BehaviourMap parent; + + private int expectedParentModCount; + + public WrappedBehaviourMap( final BehaviourMap behaviourMap ) + { + this.behaviourMap = behaviourMap; + parent = null; + expectedParentModCount = 0; + } + + @Override + public void setParent( final BehaviourMap map ) + { + parent = map; + if ( map != null ) + expectedParentModCount = parent.modCount(); + } + + @Override + public BehaviourMap getParent() + { + return parent; + } + + @Override + public synchronized void put( final String key, final Behaviour behaviour ) + { + throw new UnsupportedOperationException( getClass().getSimpleName() + " cannot be modified." ); + } + + @Override + public synchronized Behaviour get( final String key ) + { + final Behaviour behaviour = behaviourMap.get( key ); + if ( behaviour == null && parent != null ) + return parent.get( key ); + else + return behaviour; + } + + @Override + public synchronized void remove( final String key ) + { + throw new UnsupportedOperationException( getClass().getSimpleName() + " cannot be modified." ); + } + + @Override + public synchronized void clear() + { + throw new UnsupportedOperationException( getClass().getSimpleName() + " cannot be modified." ); + } + + @Override + public synchronized Map< String, Behaviour > getAllBindings() + { + final Map< String, Behaviour > allBindings = ( parent == null ) ? new HashMap<>() : parent.getAllBindings(); + + for ( final Map.Entry< String, Behaviour > entry : behaviourMap.getAllBindings().entrySet() ) + allBindings.put( entry.getKey(), entry.getValue() ); + + return allBindings; + } + + @Override + public int modCount() + { + if ( parent != null ) + { + final int m = parent.modCount(); + if ( m != expectedParentModCount ) + { + expectedParentModCount = m; + behaviourMap.remove( null ); // hack to bump + // behaviourMap.modCount + } + } + return behaviourMap.modCount(); + } +} diff --git a/src/main/java/org/scijava/ui/behaviour/util/WrappedInputMap.java b/src/main/java/org/scijava/ui/behaviour/util/WrappedInputMap.java new file mode 100644 index 0000000..ad530ec --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/util/WrappedInputMap.java @@ -0,0 +1,106 @@ +/*- + * #%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.scijava.ui.behaviour.util; + +import javax.swing.InputMap; +import javax.swing.KeyStroke; + +/** + * A view of the specified {@link InputMap} that can have its own parent. The + * wrapped {@code InputMap} should not have a parent!? + */ +public class WrappedInputMap extends InputMap +{ + private static final long serialVersionUID = 1L; + + private final InputMap inputMap; + + private InputMap parent; + + public WrappedInputMap( final InputMap inputMap ) + { + this.inputMap = inputMap; + } + + @Override + public void setParent( final InputMap map ) + { + parent = map; + } + + @Override + public InputMap getParent() + { + return parent; + } + + @Override + public void put( final KeyStroke keyStroke, final Object actionMapKey ) + { + throw new UnsupportedOperationException( getClass().getSimpleName() + " cannot be modified." ); + } + + @Override + public Object get( final KeyStroke keyStroke ) + { + final Object value = inputMap.get( keyStroke ); + if ( value == null ) + { + final InputMap parent = getParent(); + if ( parent != null ) + return parent.get( keyStroke ); + } + return value; + } + + @Override + public void remove( final KeyStroke key ) + { + throw new UnsupportedOperationException( getClass().getSimpleName() + " cannot be modified." ); + } + + @Override + public void clear() + { + throw new UnsupportedOperationException( getClass().getSimpleName() + " cannot be modified." ); + } + + @Override + public KeyStroke[] keys() + { + return inputMap.keys(); + } + + @Override + public int size() + { + return inputMap.size(); + } +} diff --git a/src/main/java/org/scijava/ui/behaviour/util/WrappedInputTriggerMap.java b/src/main/java/org/scijava/ui/behaviour/util/WrappedInputTriggerMap.java new file mode 100644 index 0000000..e97fadb --- /dev/null +++ b/src/main/java/org/scijava/ui/behaviour/util/WrappedInputTriggerMap.java @@ -0,0 +1,146 @@ +/*- + * #%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.scijava.ui.behaviour.util; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.scijava.ui.behaviour.InputTrigger; +import org.scijava.ui.behaviour.InputTriggerMap; + +/** + * A view of the specified {@link InputTriggerMap} that can have its own parent. + * The wrapped {@code InputTriggerMap} should not have a parent!? + */ +public class WrappedInputTriggerMap extends InputTriggerMap +{ + private final InputTriggerMap inputTriggerMap; + + private InputTriggerMap parent; + + private int expectedParentModCount; + + public WrappedInputTriggerMap( final InputTriggerMap inputTriggerMap ) + { + this.inputTriggerMap = inputTriggerMap; + parent = null; + expectedParentModCount = 0; + } + + @Override + public void setParent( final InputTriggerMap map ) + { + parent = map; + if ( map != null ) + expectedParentModCount = parent.modCount(); + } + + @Override + public InputTriggerMap getParent() + { + return parent; + } + + @Override + public synchronized void put( final InputTrigger inputTrigger, final String behaviourKey ) + { + throw new UnsupportedOperationException( getClass().getSimpleName() + " cannot be modified." ); + } + + @Override + public synchronized Set< String > get( final InputTrigger inputTrigger ) + { + Set< String > keys = null; + if ( parent != null ) + keys = parent.get( inputTrigger ); + else + keys = new HashSet<>(); + keys.addAll( inputTriggerMap.get( inputTrigger ) ); + return keys; + } + + @Override + public synchronized void remove( final InputTrigger inputTrigger, final String behaviourKey ) + { + throw new UnsupportedOperationException( getClass().getSimpleName() + " cannot be modified." ); + } + + @Override + public synchronized void removeAll( final InputTrigger inputTrigger ) + { + throw new UnsupportedOperationException( getClass().getSimpleName() + " cannot be modified." ); + } + + @Override + public synchronized void clear() + { + throw new UnsupportedOperationException( getClass().getSimpleName() + " cannot be modified." ); + } + + @Override + public synchronized Map< InputTrigger, Set< String > > getAllBindings() + { + final Map< InputTrigger, Set< String > > allBindings; + if ( parent != null ) + allBindings = parent.getAllBindings(); + else + allBindings = new HashMap<>(); + + for ( final Map.Entry< InputTrigger, Set< String > > entry : inputTriggerMap.getAllBindings().entrySet() ) + { + final InputTrigger inputTrigger = entry.getKey(); + if ( entry.getValue() == null || entry.getValue().isEmpty() ) + continue; + + final Set< String > behaviourKeys = allBindings.computeIfAbsent( inputTrigger, k -> new HashSet<>() ); + behaviourKeys.addAll( entry.getValue() ); + } + + return allBindings; + } + + @Override + public int modCount() + { + if ( parent != null ) + { + final int m = parent.modCount(); + if ( m != expectedParentModCount ) + { + expectedParentModCount = m; + inputTriggerMap.remove( null, null ); // hack to bump + // inputTriggerMap.modCount + } + } + return inputTriggerMap.modCount(); + } +} diff --git a/src/test/java/bdv/behaviour/UsageExample.java b/src/test/java/bdv/behaviour/UsageExample.java deleted file mode 100644 index cd881b3..0000000 --- a/src/test/java/bdv/behaviour/UsageExample.java +++ /dev/null @@ -1,142 +0,0 @@ -package bdv.behaviour; - -import java.awt.Dimension; -import java.io.StringReader; -import java.util.List; - -import javax.swing.JFrame; -import javax.swing.JPanel; - -import bdv.behaviour.io.InputTriggerConfig; -import bdv.behaviour.io.InputTriggerDescription; -import bdv.behaviour.io.yaml.YamlConfigIO; - -public class UsageExample -{ - static class MyDragBehaviour implements DragBehaviour - { - private final String name; - - public MyDragBehaviour( final String name ) - { - this.name = name; - } - - @Override - public void init( final int x, final int y ) - { - System.out.println( name + ": init(" + x + ", " + y + ")" ); - } - - @Override - public void drag( final int x, final int y ) - { - System.out.println( name + ": drag(" + x + ", " + y + ")" ); - } - - @Override - public void end( final int x, final int y ) - { - System.out.println( name + ": end(" + x + ", " + y + ")" ); - } - } - - static class MyClickBehaviour implements ClickBehaviour - { - private final String name; - - public MyClickBehaviour( final String name ) - { - this.name = name; - } - - @Override - public void click( final int x, final int y ) - { - System.out.println( name + ": click(" + x + ", " + y + ")" ); - } - } - - static class MyScrollBehaviour implements ScrollBehaviour - { - private final String name; - - public MyScrollBehaviour( final String name ) - { - this.name = name; - } - - @Override - public void scroll( final double wheelRotation, final boolean isHorizontal, final int x, final int y ) - { - System.out.println( name + ": scroll(" + wheelRotation + ", " + isHorizontal + ", " + x + ", " + y + ")" ); - } - } - - public static void main( final String[] args ) - { - /* - * Create InputTriggerMap and BehaviourMap. This is analogous to - * javax.swing InputMap and ActionMap. - */ - final InputTriggerMap inputMap = new InputTriggerMap(); - final BehaviourMap behaviourMap = new BehaviourMap(); - - /* - * Create a MouseAndKeyHandler that dispatches to registered Behaviours. - */ - final MouseAndKeyHandler handler = new MouseAndKeyHandler(); - handler.setInputMap( inputMap ); - handler.setBehaviourMap( behaviourMap ); - - /* - * Display a JPanel with the MouseAndKeyHandler registered. - */ - final JPanel panel = new JPanel(); - panel.setPreferredSize( new Dimension( 400, 400 ) ); - panel.addMouseListener( handler ); - panel.addMouseMotionListener( handler ); - panel.addMouseWheelListener( handler ); - panel.addKeyListener( handler ); - final JFrame frame = new JFrame( "UsageExample" ); - frame.add( panel ); - frame.pack(); - frame.setVisible( true ); - panel.requestFocusInWindow(); - - /* - * Load YAML config "file". - */ - final StringReader reader = new StringReader( "---\n" + - "- !mapping" + "\n" + - " action: drag1" + "\n" + - " contexts: [all]" + "\n" + - " triggers: [button1, G]" + "\n" + - "- !mapping" + "\n" + - " action: scroll1" + "\n" + - " contexts: [all]" + "\n" + - " triggers: [scroll]" + "\n" + - "" ); - final List< InputTriggerDescription > triggers = YamlConfigIO.read( reader ); - final InputTriggerConfig config = new InputTriggerConfig( triggers ); - - /* - * Create behaviours and input mappings. - */ - behaviourMap.put( "drag1", new MyDragBehaviour( "drag1" ) ); - behaviourMap.put( "drag2", new MyDragBehaviour( "drag2" ) ); - behaviourMap.put( "scroll1", new MyScrollBehaviour( "scroll1" ) ); - behaviourMap.put( "click1", new MyClickBehaviour( "click1" ) ); - - final InputTriggerAdder adder = config.inputTriggerAdder( inputMap, "all" ); - adder.put( "drag1" ); // put input trigger as defined in config - adder.put( "drag2", "button1", "shift A" ); // default triggers if not defined in config - adder.put( "scroll1", "alt scroll" ); - adder.put( "click1", "button3", "B" ); - - /* - * See bdv.viewer.TriggerBehaviourBindings for chaining InputMaps and BehaviourMaps. - * (might move here in the future) - */ - } -} diff --git a/src/test/java/bdv/behaviour/EventsInteractiveTest.java b/src/test/java/org/scijava/ui/behaviour/EventsInteractiveTest.java similarity index 80% rename from src/test/java/bdv/behaviour/EventsInteractiveTest.java rename to src/test/java/org/scijava/ui/behaviour/EventsInteractiveTest.java index 08bec6a..3f79aad 100644 --- a/src/test/java/bdv/behaviour/EventsInteractiveTest.java +++ b/src/test/java/org/scijava/ui/behaviour/EventsInteractiveTest.java @@ -1,4 +1,33 @@ -package bdv.behaviour; +/*- + * #%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.scijava.ui.behaviour; import java.awt.Dimension; import java.awt.KeyEventDispatcher; @@ -12,14 +41,6 @@ import javax.swing.JFrame; import javax.swing.JPanel; -import bdv.behaviour.BehaviourMap; -import bdv.behaviour.ClickBehaviour; -import bdv.behaviour.DragBehaviour; -import bdv.behaviour.InputTrigger; -import bdv.behaviour.InputTriggerMap; -import bdv.behaviour.MouseAndKeyHandler; -import bdv.behaviour.ScrollBehaviour; - public class EventsInteractiveTest { static class MyDragBehaviour implements DragBehaviour @@ -148,6 +169,7 @@ public void mousePressed( final MouseEvent e ) panel.addMouseMotionListener( handler ); panel.addMouseWheelListener( handler ); panel.addKeyListener( handler ); + panel.addFocusListener( handler ); final InputTriggerMap inputMap = new InputTriggerMap(); final BehaviourMap behaviourMap = new BehaviourMap(); diff --git a/src/test/java/org/scijava/ui/behaviour/MultiWindowExample.java b/src/test/java/org/scijava/ui/behaviour/MultiWindowExample.java new file mode 100644 index 0000000..5134999 --- /dev/null +++ b/src/test/java/org/scijava/ui/behaviour/MultiWindowExample.java @@ -0,0 +1,146 @@ +/*- + * #%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.scijava.ui.behaviour; + +import java.awt.Dimension; + +import javax.swing.JFrame; +import javax.swing.JPanel; + +import org.scijava.ui.behaviour.io.InputTriggerConfig; +import org.scijava.ui.behaviour.util.AbstractNamedBehaviour; +import org.scijava.ui.behaviour.util.Behaviours; + +public class MultiWindowExample +{ + static class MyDragBehaviour extends AbstractNamedBehaviour implements DragBehaviour + { + public MyDragBehaviour( final String name ) + { + super( name ); + } + + @Override + public void init( final int x, final int y ) + { + System.out.println( name() + ": init(" + x + ", " + y + ")" ); + } + + @Override + public void drag( final int x, final int y ) + { + System.out.println( name() + ": drag(" + x + ", " + y + ")" ); + } + + @Override + public void end( final int x, final int y ) + { + System.out.println( name() + ": end(" + x + ", " + y + ")" ); + } + } + + static class MyClickBehaviour extends AbstractNamedBehaviour implements ClickBehaviour + { + public MyClickBehaviour( final String name ) + { + super( name ); + } + + @Override + public void click( final int x, final int y ) + { + System.out.println( name() + ": click(" + x + ", " + y + ")" ); + } + } + + static class BehaviourFrame + { + final JFrame frame; + + final JPanel panel; + + final String title; + + final Behaviours behaviours; + + final MouseAndKeyHandler handler; + + public BehaviourFrame( final String title, final String prefix ) + { + this.title = title; + + /* + * Create InputTriggerMap and BehaviourMap. This is analogous to + * javax.swing InputMap and ActionMap. + */ + final InputTriggerMap inputMap = new InputTriggerMap(); + final BehaviourMap behaviourMap = new BehaviourMap(); + + /* + * Create a MouseAndKeyHandler that dispatches to registered Behaviours. + */ + handler = new MouseAndKeyHandler(); + handler.setInputMap( inputMap ); + handler.setBehaviourMap( behaviourMap ); + + behaviours = new Behaviours( inputMap, behaviourMap, new InputTriggerConfig() ); + behaviours.namedBehaviour( new MyDragBehaviour( prefix + "drag m" ), "button1" ); + behaviours.namedBehaviour( new MyDragBehaviour( prefix + "drag k" ), "K" ); + behaviours.namedBehaviour( new MyClickBehaviour( prefix + "click c " ), "C" ); + + /* + * Display a JPanel with the MouseAndKeyHandler registered. + */ + panel = new JPanel(); + panel.setPreferredSize( new Dimension( 400, 400 ) ); + panel.addMouseListener( handler ); + panel.addMouseMotionListener( handler ); + panel.addMouseWheelListener( handler ); + panel.addKeyListener( handler ); + panel.addFocusListener( handler ); + frame = new JFrame( title ); + frame.add( panel ); + frame.pack(); + frame.setVisible( true ); + panel.requestFocusInWindow(); + } + } + + public static void main( final String[] args ) + { + final BehaviourFrame f1 = new BehaviourFrame( "frame1", "f1 : " ); + final BehaviourFrame f2 = new BehaviourFrame( "frame2", "f2 : " ); + f2.frame.setLocation( 300, 0 ); + + final KeyPressedManager manager = new KeyPressedManager(); + f1.handler.setKeypressManager( manager, f1.panel ); + f2.handler.setKeypressManager( manager, f2.panel ); + } +} diff --git a/src/test/java/org/scijava/ui/behaviour/UsageExample.java b/src/test/java/org/scijava/ui/behaviour/UsageExample.java new file mode 100644 index 0000000..95e4830 --- /dev/null +++ b/src/test/java/org/scijava/ui/behaviour/UsageExample.java @@ -0,0 +1,167 @@ +/*- + * #%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.scijava.ui.behaviour; + +import java.awt.Dimension; +import java.io.StringReader; +import java.util.List; + +import javax.swing.JFrame; +import javax.swing.JPanel; + +import org.scijava.ui.behaviour.io.InputTriggerConfig; +import org.scijava.ui.behaviour.io.InputTriggerDescription; +import org.scijava.ui.behaviour.io.yaml.YamlConfigIO; +import org.scijava.ui.behaviour.util.AbstractNamedBehaviour; +import org.scijava.ui.behaviour.util.Behaviours; + +public class UsageExample +{ + static class MyDragBehaviour extends AbstractNamedBehaviour implements DragBehaviour + { + public MyDragBehaviour( final String name ) + { + super( name ); + } + + @Override + public void init( final int x, final int y ) + { + System.out.println( name() + ": init(" + x + ", " + y + ")" ); + } + + @Override + public void drag( final int x, final int y ) + { + System.out.println( name() + ": drag(" + x + ", " + y + ")" ); + } + + @Override + public void end( final int x, final int y ) + { + System.out.println( name() + ": end(" + x + ", " + y + ")" ); + } + } + + static class MyClickBehaviour extends AbstractNamedBehaviour implements ClickBehaviour + { + public MyClickBehaviour( final String name ) + { + super( name ); + } + + @Override + public void click( final int x, final int y ) + { + System.out.println( name() + ": click(" + x + ", " + y + ")" ); + } + } + + static class MyScrollBehaviour extends AbstractNamedBehaviour implements ScrollBehaviour + { + public MyScrollBehaviour( final String name ) + { + super( name ); + } + + @Override + public void scroll( final double wheelRotation, final boolean isHorizontal, final int x, final int y ) + { + System.out.println( name() + ": scroll(" + wheelRotation + ", " + isHorizontal + ", " + x + ", " + y + ")" ); + } + } + + public static void main( final String[] args ) + { + /* + * Create InputTriggerMap and BehaviourMap. This is analogous to + * javax.swing InputMap and ActionMap. + */ + final InputTriggerMap inputMap = new InputTriggerMap(); + final BehaviourMap behaviourMap = new BehaviourMap(); + + /* + * Create a MouseAndKeyHandler that dispatches to registered Behaviours. + */ + final MouseAndKeyHandler handler = new MouseAndKeyHandler(); + handler.setInputMap( inputMap ); + handler.setBehaviourMap( behaviourMap ); + + /* + * Display a JPanel with the MouseAndKeyHandler registered. + */ + final JPanel panel = new JPanel(); + panel.setPreferredSize( new Dimension( 400, 400 ) ); + panel.addMouseListener( handler ); + panel.addMouseMotionListener( handler ); + panel.addMouseWheelListener( handler ); + panel.addKeyListener( handler ); + panel.addFocusListener( handler ); + final JFrame frame = new JFrame( "UsageExample" ); + frame.add( panel ); + frame.pack(); + frame.setVisible( true ); + panel.requestFocusInWindow(); + + /* + * Load YAML config "file". + */ + final StringReader reader = new StringReader( "---\n" + + "- !mapping" + "\n" + + " action: drag1" + "\n" + + " contexts: [all]" + "\n" + + " triggers: [button1, G]" + "\n" + + "- !mapping" + "\n" + + " action: scroll1" + "\n" + + " contexts: [all]" + "\n" + + " triggers: [scroll]" + "\n" + + "" ); + final List< InputTriggerDescription > triggers = YamlConfigIO.read( reader ); + final InputTriggerConfig config = new InputTriggerConfig( triggers ); + + /* + * Create behaviours and input mappings. + */ + Behaviours behaviours = new Behaviours( inputMap, behaviourMap, config, "all" ); + behaviours.namedBehaviour( new MyDragBehaviour( "drag1" ) ); // put input trigger as defined in config + behaviours.namedBehaviour( new MyDragBehaviour( "drag2" ), + "button1", "shift A | G" ); // default triggers if not defined in config + behaviours.namedBehaviour( new MyScrollBehaviour( "scroll1" ), + "alt scroll" ); + behaviours.namedBehaviour( new MyClickBehaviour( "click1" ), + "button3", "B | all" ); + + /* + * See org.scijava.ui.behaviour.util.InputActionBindings and + * org.scijava.ui.behaviour.util.TriggerBehaviourBindings for chaining + * InputMaps and BehaviourMaps. + */ + } +} diff --git a/src/test/java/org/scijava/ui/behaviour/io/gui/VisualEditorPanelDemo.java b/src/test/java/org/scijava/ui/behaviour/io/gui/VisualEditorPanelDemo.java new file mode 100644 index 0000000..3c0e5ea --- /dev/null +++ b/src/test/java/org/scijava/ui/behaviour/io/gui/VisualEditorPanelDemo.java @@ -0,0 +1,177 @@ +/*- + * #%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.scijava.ui.behaviour.io.gui; + +import java.awt.EventQueue; +import java.io.StringReader; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.swing.JFrame; +import javax.swing.SwingUtilities; +import javax.swing.UIManager; +import javax.swing.UnsupportedLookAndFeelException; + +import org.scijava.ui.behaviour.io.InputTriggerConfig; +import org.scijava.ui.behaviour.io.InputTriggerDescription; +import org.scijava.ui.behaviour.io.yaml.YamlConfigIO; + +public class VisualEditorPanelDemo +{ + /* + * DEMO METHODS. + */ + + private static InputTriggerConfig getDemoConfig() + { + final StringReader reader = new StringReader( "---\n" + + "- !mapping" + "\n" + + " action: fluke" + "\n" + + " contexts: [all]" + "\n" + + " triggers: [F]" + "\n" + + "- !mapping" + "\n" + + " action: drag1" + "\n" + + " contexts: [all]" + "\n" + + " triggers: [button1, win G]" + "\n" + + "- !mapping" + "\n" + + " action: scroll1" + "\n" + + " contexts: [all]" + "\n" + + " triggers: [scroll]" + "\n" + + "- !mapping" + "\n" + + " action: scroll1" + "\n" + + " contexts: [trackscheme, mamut]" + "\n" + + " triggers: [shift D]" + "\n" + + "- !mapping" + "\n" + + " action: destroy the world" + "\n" + + " contexts: [mamut]" + "\n" + + " triggers: [control A]" + "\n" + + "" ); + final List< InputTriggerDescription > triggers = YamlConfigIO.read( reader ); + return new InputTriggerConfig( triggers ); + } + + private static Map< Command, String > getDemoCommands() + { + return new CommandDescriptionBuilder() + .addCommand( "drag1", "mamut", "Move an item around the editor." ) + .addCommand( "drag1", "trackscheme", "Move an item around the editor." ) + .addCommand( "drag1", "other", "Move an item around the editor." ) + .addCommand( "Elude", "other", "Refuse to answer the question." ) + .addCommand( "scroll1", "mamut", null ) + .addCommand( "destroy the world", "all", "Make a disgusting coffee for breakfast. \n" + + "For this one, you are by yourself. Good luck and know that we are with you. This is a long line. Hopefully long engouh.\n" + + "Hey, what about we add:\n" + + "tabulation1\ttabulation2\n" + + "lalallala\ttrollololo." ) + .addCommand( "ride the dragon", "all", "Go to work by bike." ) + .addCommand( "Punish", "all", "Go to work by parisian metro." ) + .addCommand( "make some coffee", "mamut", null ) + .addCommand( "make some coffee", "trackscheme", "Make a decent coffee." ) + .get(); + } + + /** + * Utility class that is used to build a map of {@link Command}s to their + * description. + * + * @author Jean-Yves Tinevez + */ + static class CommandDescriptionBuilder + { + + /** + * The map of {@link Command} to description of what the command does. + */ + private final HashMap< Command, String > map; + + public CommandDescriptionBuilder() + { + this.map = new HashMap<>(); + } + + public CommandDescriptionBuilder addCommand( final String name, final String context, final String description ) + { + final Command command = new Command( name, context ); + map.put( command, description ); + return this; + } + + /** + * Returns the map of {@link Command} to description of what the command + * does. + * + * @return a new immutable map + */ + public Map< Command, String > get() + { + return Collections.unmodifiableMap( map ); + } + + } + + /** + * Launch the application. + * + * @param args + * + * @throws UnsupportedLookAndFeelException + * @throws IllegalAccessException + * @throws InstantiationException + * @throws ClassNotFoundException + */ + public static void main( final String[] args ) throws ClassNotFoundException, InstantiationException, IllegalAccessException, UnsupportedLookAndFeelException + { + UIManager.setLookAndFeel( UIManager.getSystemLookAndFeelClassName() ); + EventQueue.invokeLater( new Runnable() + { + @Override + public void run() + { + try + { + final JFrame frame = new JFrame( "Behaviour Key bindings editor" ); + final VisualEditorPanel editorPanel = new VisualEditorPanel( getDemoConfig(), getDemoCommands() ); + editorPanel.modelChangedListeners().add( () -> System.out.println( "Model (GUI only) changed @ " + new java.util.Date().toString() ) ); + editorPanel.configCommittedListeners().add( () -> System.out.println( "Config changed, Apply button pressed @ " + new java.util.Date().toString() ) ); + SwingUtilities.updateComponentTreeUI( VisualEditorPanel.fileChooser ); + frame.getContentPane().add( editorPanel ); + frame.pack(); + frame.setVisible( true ); + } + catch ( final Exception e ) + { + e.printStackTrace(); + } + } + } ); + } +} diff --git a/src/test/java/bdv/behaviour/io/json/JSonInteractiveTest.java b/src/test/java/org/scijava/ui/behaviour/io/json/JsonInteractiveTest.java similarity index 50% rename from src/test/java/bdv/behaviour/io/json/JSonInteractiveTest.java rename to src/test/java/org/scijava/ui/behaviour/io/json/JsonInteractiveTest.java index c66a66b..7953859 100644 --- a/src/test/java/bdv/behaviour/io/json/JSonInteractiveTest.java +++ b/src/test/java/org/scijava/ui/behaviour/io/json/JsonInteractiveTest.java @@ -1,4 +1,33 @@ -package bdv.behaviour.io.json; +/*- + * #%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.scijava.ui.behaviour.io.json; import java.io.BufferedReader; import java.io.File; @@ -7,9 +36,9 @@ import java.util.ArrayList; import java.util.List; -import bdv.behaviour.io.InputTriggerDescription; +import org.scijava.ui.behaviour.io.InputTriggerDescription; -public class JSonInteractiveTest +public class JsonInteractiveTest { public static void main( final String[] args ) throws IOException { @@ -22,14 +51,14 @@ public static void main( final String[] args ) throws IOException final List< InputTriggerDescription > list = JsonConfigIO.read( fileName ); System.out.println( list ); } - + /* * TEST WRITE */ System.out.println( "\nWriting to file " + fileName ); - final ArrayList< InputTriggerDescription > keyMappings = new ArrayList< InputTriggerDescription >(); + final ArrayList< InputTriggerDescription > keyMappings = new ArrayList<>(); keyMappings.add( new InputTriggerDescription( new String[] { "button1" }, "ts select vertex", "trackscheme" ) ); keyMappings.add( new InputTriggerDescription( new String[] { "button2" }, "ts select edge", "bdv", "trackscheme" ) ); keyMappings.add( new InputTriggerDescription( new String[] { "A" }, "navigate", "bdv", "trackscheme" ) ); @@ -50,11 +79,11 @@ public static void main( final String[] args ) throws IOException /* * TEST READ */ - + System.out.println( "\nReading from file:" ); final List< InputTriggerDescription > list = JsonConfigIO.read( fileName ); System.out.println( list ); - + } } diff --git a/src/test/java/bdv/behaviour/io/yaml/YamlInteractiveTest.java b/src/test/java/org/scijava/ui/behaviour/io/yaml/YamlInteractiveTest.java similarity index 61% rename from src/test/java/bdv/behaviour/io/yaml/YamlInteractiveTest.java rename to src/test/java/org/scijava/ui/behaviour/io/yaml/YamlInteractiveTest.java index 4a0f0d2..4dcb4ac 100644 --- a/src/test/java/bdv/behaviour/io/yaml/YamlInteractiveTest.java +++ b/src/test/java/org/scijava/ui/behaviour/io/yaml/YamlInteractiveTest.java @@ -1,4 +1,33 @@ -package bdv.behaviour.io.yaml; +/*- + * #%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.scijava.ui.behaviour.io.yaml; import java.io.BufferedReader; import java.io.File; @@ -8,7 +37,7 @@ import java.util.ArrayList; import java.util.List; -import bdv.behaviour.io.InputTriggerDescription; +import org.scijava.ui.behaviour.io.InputTriggerDescription; public class YamlInteractiveTest { @@ -23,14 +52,14 @@ public static void main( final String[] args ) throws IOException final List< InputTriggerDescription > list = YamlConfigIO.read( fileName ); System.out.println( list ); } - + /* * TEST WRITE */ System.out.println( "\nWriting to file " + fileName ); - final ArrayList< InputTriggerDescription > keyMappings = new ArrayList< InputTriggerDescription >(); + final ArrayList< InputTriggerDescription > keyMappings = new ArrayList<>(); keyMappings.add( new InputTriggerDescription( new String[] { "button1" }, "ts select vertex", "trackscheme" ) ); keyMappings.add( new InputTriggerDescription( new String[] { "button2" }, "ts select edge", "bdv", "trackscheme" ) ); keyMappings.add( new InputTriggerDescription( new String[] { "A" }, "navigate", "bdv", "trackscheme" ) ); @@ -51,25 +80,25 @@ public static void main( final String[] args ) throws IOException /* * TEST READ */ - + System.out.println( "\nReading from file:" ); List< InputTriggerDescription > list = YamlConfigIO.read( fileName ); System.out.println( list ); - + /* * TEST HETEROGENOUS DATA */ - + System.out.println( "\nMalformed data." ); - + final FileWriter writer = new FileWriter( fileName, false ); - + // Write a tag with a mistake writer.write( "- !mapping\n" ); writer.write( "acton: paste\n"); writer.write( "contexts: []\n" ); writer.write( "key: ctrl C\n"); - + // Write a tag with a serious problem writer.write( "- !mapping\n" ); writer.write( "action: pastecontexts: []\n" ); @@ -80,18 +109,18 @@ public static void main( final String[] args ) throws IOException writer.write( "action: zap2\n"); writer.write( "contexts: []\n" ); writer.write( "key: Z2\n"); - + // Write a tag that is not handled writer.write( "---\n- !display\n" ); writer.write( "brightness: 100\n" ); writer.write( "contrast: 50\n" ); writer.close(); - + System.out.println( "\nReading from file:" ); list = YamlConfigIO.read( fileName ); System.out.println( list ); - + } }