diff --git a/BDV N5 format.md b/BDV N5 format.md
new file mode 100644
index 0000000000000000000000000000000000000000..2c4cf4b2ffcc22767b7c06fd972b4a245e77547d
--- /dev/null
+++ b/BDV N5 format.md	
@@ -0,0 +1,57 @@
+# BigDataViewer N5 format
+
+The BigDataViewer N5 back-end (`format="bdv.n5"`) requires a N5 dataset with a specific group hierarchy and attributes.
+
+Multi-channel (-angle, -tile, -illumination) time-series as
+multi-resolution 3D stacks are organized in the following N5 hierarchy:
+
+```
+\
+├── setup0
+│   ├── attributes.json {"downsamplingFactors":[[1,1,1],[2,2,2]],"dataType":"uint8"}
+│   ├── timepoint0
+│   │   ├── s0
+│   │   │   ├── attributes.json {"dataType":"uint8","compression":{"type":"bzip2","blockSize":9},"blockSize":[16,16,16],"dimensions":[400,400,25]}
+│   │   │   ┊
+│   │   │
+│   │   ├── s1
+│   │   ┊
+│   │
+│   ├── timepoint1
+│   ┊
+│
+├── setup1
+┊
+```
+
+Each 3D stack is stored as a dataset with path formatted as `/setup%d/timepoint%d/s%d`.
+Here, `setup` number is flattened integer ID of channel, angle, tile, illumination, etc.,
+`timepoint` is the integer ID of the time point, and `s` is the scale level of the multi-resolution pyramid.
+
+## setup attributes
+Each `setup` group has attributes
+```
+"downsamplingFactors" : [[1,1,1], [2,2,1], ...]
+"dataType":"uint8"
+```
+that specify downsampling scheme and datatype, which are the same for all timepoints.
+`downsamplingFactors` specifies power-of-two downscaling factors for each scale level (with respect to full resolution `s0`, which always has `[1,1,1]`).
+`dataType` is one of {uint8, uint16, uint32, uint64, int8, int16, int32, int64, float32, float64}.
+
+> TODO: Additional metadata (for example identifying `setup3` as channel 3, tile 124, etc.) could be replicated from the XML.
+The idea would be that parts of a dataset can be used independent of BDV, without the XML.
+We should agree on standard attributes for this.
+
+## timepoint attributes
+`timepoint` has no mandatory attributes.
+
+For compatibility with Paintera, when exporting to N5 we put the `"multiScale" : true`, and `"resolution" : [x,y,z]` attributes.
+
+## scale level attributes
+`s0`, `s1`, etc. have the mandatory N5 dataset attributes.
+
+For compatibility with Paintera, when exporting to N5 we put the `"downsamplingFactors": [x,y,z]` attribute.
+
+> TODO: Additional metadata (for example scaled resolution and affine transform) could be replicated from the XML.
+The idea would be that an individual stack can be used independent of BDV, without the XML.
+We should agree on standard attributes for this.
diff --git a/LICENSE.txt b/LICENSE.txt
index 5f511dfe6d816a873ca85ecd7fcb2df05eeddbc1..74eeded88037f7410a7d90acc14fc8d221f7c10b 100644
--- a/LICENSE.txt
+++ b/LICENSE.txt
@@ -1,5 +1,4 @@
-Copyright (c) 2012 - 2016, Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
-Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+Copyright (c) 2012 - 2020, BigDataViewer developers.
 All rights reserved.
 
 Redistribution and use in source and binary forms, with or without modification,
diff --git a/pom.xml b/pom.xml
index 3bcf22161bb8852cd056600496f0306a6ebff91e..b3fcafcc73d3d80e9061bb1bc1157e5b242ab1a6 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,13 +5,13 @@
 	<parent>
 		<groupId>org.scijava</groupId>
 		<artifactId>pom-scijava</artifactId>
-		<version>27.0.1</version>
+		<version>29.2.1</version>
 		<relativePath />
 	</parent>
 
 	<groupId>sc.fiji</groupId>
 	<artifactId>bigdataviewer-core</artifactId>
-	<version>7.0.1-SNAPSHOT</version>
+	<version>10.0.3-SNAPSHOT</version>
 
 	<name>BigDataViewer Core</name>
 	<description>BigDataViewer core classes with minimal dependencies.</description>
@@ -134,12 +134,9 @@
 		<package-name>bdv</package-name>
 		<license.licenseName>bsd_2</license.licenseName>
 		<license.copyrightOwners>BigDataViewer developers.</license.copyrightOwners>
-		<enforcer.skip>true</enforcer.skip>
 
 		<!-- NB: Deploy releases to the SciJava Maven repository. -->
 		<releaseProfiles>deploy-to-scijava</releaseProfiles>
-
-		<imglib2-cache.version>1.0.0-beta-12</imglib2-cache.version>
 	</properties>
 
 	<repositories>
@@ -162,10 +159,6 @@
 			<groupId>net.imglib2</groupId>
 			<artifactId>imglib2-cache</artifactId>
 		</dependency>
-		<dependency>
-			<groupId>net.imglib2</groupId>
-			<artifactId>imglib2-ui</artifactId>
-		</dependency>
 		<dependency>
 			<groupId>net.imglib2</groupId>
 			<artifactId>imglib2-algorithm</artifactId>
@@ -193,8 +186,20 @@
 		<dependency>
 			<groupId>org.scijava</groupId>
 			<artifactId>scijava-listeners</artifactId>
-			<version>1.0.0-beta-2</version>
 		</dependency>
+		<dependency>
+			<groupId>org.janelia.saalfeldlab</groupId>
+			<artifactId>n5-imglib2</artifactId>
+		</dependency>
+		<dependency>
+			<groupId>org.janelia.saalfeldlab</groupId>
+			<artifactId>n5</artifactId>
+		</dependency>
+		<dependency>
+			<groupId>com.miglayout</groupId>
+			<artifactId>miglayout-swing</artifactId>
+		</dependency>
+
 		<!-- test dependencies -->
 		<dependency>
 			<groupId>junit</groupId>
diff --git a/src/main/java/bdv/AbstractCachedViewerSetupImgLoader.java b/src/main/java/bdv/AbstractCachedViewerSetupImgLoader.java
index 10a0fd1049bcae0e8d44af7d6825aa2475fbfdb9..4858bf872f429b9da1b6e504ab840edf0a0e11ff 100644
--- a/src/main/java/bdv/AbstractCachedViewerSetupImgLoader.java
+++ b/src/main/java/bdv/AbstractCachedViewerSetupImgLoader.java
@@ -1,3 +1,31 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
 package bdv;
 
 import bdv.img.cache.CacheArrayLoader;
@@ -16,7 +44,7 @@ import net.imglib2.type.NativeType;
 /**
  * Abstract {@link ViewerSetupImgLoader} with a VolatileGlobalCellCache.
  *
- * @author Stephan Saalfeld &lt;saalfelds@janelia.hhmi.org&gt;
+ * @author Stephan Saalfeld
  */
 abstract public class AbstractCachedViewerSetupImgLoader< T extends NativeType< T > , V extends Volatile< T > & NativeType< V >, A extends VolatileAccess >
 	extends AbstractViewerSetupImgLoader< T, V >
diff --git a/src/main/java/bdv/AbstractSpimSource.java b/src/main/java/bdv/AbstractSpimSource.java
index 7c5f7cd3a689bbdf377d20fe9771ef0ff066e098..2660b932d3a2823f22c3b60fdd0d64d2a951d726 100644
--- a/src/main/java/bdv/AbstractSpimSource.java
+++ b/src/main/java/bdv/AbstractSpimSource.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/AbstractViewerSetupImgLoader.java b/src/main/java/bdv/AbstractViewerSetupImgLoader.java
index 46550d3fa07cbb495eb7d671514208a3f42c909c..af7ce24dc5069c54528c11fe6eab895c7e93c0dc 100644
--- a/src/main/java/bdv/AbstractViewerSetupImgLoader.java
+++ b/src/main/java/bdv/AbstractViewerSetupImgLoader.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/BehaviourTransformEventHandler3D.java b/src/main/java/bdv/BehaviourTransformEventHandler3D.java
deleted file mode 100644
index e0f363409e9c7eeb84bd0b7580c2d190a17d616d..0000000000000000000000000000000000000000
--- a/src/main/java/bdv/BehaviourTransformEventHandler3D.java
+++ /dev/null
@@ -1,498 +0,0 @@
-/*-
- * #%L
- * BigDataViewer core classes with minimal dependencies
- * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
- * %%
- * 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 bdv;
-
-import org.scijava.ui.behaviour.Behaviour;
-import org.scijava.ui.behaviour.ClickBehaviour;
-import org.scijava.ui.behaviour.DragBehaviour;
-import org.scijava.ui.behaviour.ScrollBehaviour;
-import org.scijava.ui.behaviour.io.InputTriggerConfig;
-import org.scijava.ui.behaviour.util.Behaviours;
-import org.scijava.ui.behaviour.util.TriggerBehaviourBindings;
-
-import net.imglib2.realtransform.AffineTransform3D;
-import net.imglib2.ui.TransformEventHandler;
-import net.imglib2.ui.TransformEventHandlerFactory;
-import net.imglib2.ui.TransformListener;
-
-/**
- * A {@link TransformEventHandler} that changes an {@link AffineTransform3D}
- * through a set of {@link Behaviour}s.
- *
- * @author Stephan Saalfeld
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
- */
-public class BehaviourTransformEventHandler3D implements BehaviourTransformEventHandler< AffineTransform3D >
-{
-	public static TransformEventHandlerFactory< AffineTransform3D > factory()
-	{
-		return new BehaviourTransformEventHandler3DFactory();
-	}
-
-	public static class BehaviourTransformEventHandler3DFactory implements BehaviourTransformEventHandlerFactory< AffineTransform3D >
-	{
-		private InputTriggerConfig config = new InputTriggerConfig();
-
-		@Override
-		public void setConfig( final InputTriggerConfig config )
-		{
-			this.config = config;
-		}
-
-		@Override
-		public BehaviourTransformEventHandler3D create( final TransformListener< AffineTransform3D > transformListener )
-		{
-			return new BehaviourTransformEventHandler3D( transformListener, config );
-		}
-	}
-
-	/**
-	 * Current source to screen transform.
-	 */
-	protected final AffineTransform3D affine = new AffineTransform3D();
-
-	/**
-	 * Whom to notify when the {@link #affine current transform} is changed.
-	 */
-	private TransformListener< AffineTransform3D > listener;
-
-	/**
-	 * Copy of {@link #affine current transform} when mouse dragging started.
-	 */
-	final protected AffineTransform3D affineDragStart = new AffineTransform3D();
-
-	/**
-	 * Coordinates where mouse dragging started.
-	 */
-	protected double oX, oY;
-
-	/**
-	 * Current rotation axis for rotating with keyboard, indexed {@code x->0, y->1,
-	 * z->2}.
-	 */
-	protected int axis = 0;
-
-	/**
-	 * The screen size of the canvas (the component displaying the image and
-	 * generating mouse events).
-	 */
-	protected int canvasW = 1, canvasH = 1;
-
-	/**
-	 * Screen coordinates to keep centered while zooming or rotating with the
-	 * keyboard. These are set to <em>(canvasW/2, canvasH/2)</em>
-	 */
-	protected int centerX = 0, centerY = 0;
-
-	protected final Behaviours behaviours;
-
-	public BehaviourTransformEventHandler3D( final TransformListener< AffineTransform3D > listener, final InputTriggerConfig config )
-	{
-		this.listener = listener;
-
-		final String DRAG_TRANSLATE = "drag translate";
-		final String ZOOM_NORMAL = "scroll zoom";
-		final String SELECT_AXIS_X = "axis x";
-		final String SELECT_AXIS_Y = "axis y";
-		final String SELECT_AXIS_Z = "axis z";
-
-		final double[] speed =      { 1.0,     10.0,     0.1 };
-		final String[] SPEED_NAME = {  "",  " fast", " slow" };
-		final String[] speedMod =   {  "", "shift ", "ctrl " };
-
-		final String DRAG_ROTATE = "drag rotate";
-		final String SCROLL_Z = "scroll browse z";
-		final String ROTATE_LEFT = "rotate left";
-		final String ROTATE_RIGHT = "rotate right";
-		final String KEY_ZOOM_IN = "zoom in";
-		final String KEY_ZOOM_OUT = "zoom out";
-		final String KEY_FORWARD_Z = "forward z";
-		final String KEY_BACKWARD_Z = "backward z";
-
-		behaviours = new Behaviours( config, "bdv" );
-
-		behaviours.behaviour( new TranslateXY(), DRAG_TRANSLATE, "button2", "button3" );
-		behaviours.behaviour( new Zoom( speed[ 0 ] ), ZOOM_NORMAL, "meta scroll", "ctrl shift scroll" );
-		behaviours.behaviour( new SelectRotationAxis( 0 ), SELECT_AXIS_X, "X" );
-		behaviours.behaviour( new SelectRotationAxis( 1 ), SELECT_AXIS_Y, "Y" );
-		behaviours.behaviour( new SelectRotationAxis( 2 ), SELECT_AXIS_Z, "Z" );
-
-		for ( int s = 0; s < 3; ++s )
-		{
-			behaviours.behaviour( new Rotate( speed[ s ] ), DRAG_ROTATE + SPEED_NAME[ s ], speedMod[ s ] + "button1" );
-			behaviours.behaviour( new TranslateZ( speed[ s ] ), SCROLL_Z + SPEED_NAME[ s ], speedMod[ s ] + "scroll" );
-			behaviours.behaviour( new KeyRotate( speed[ s ] ), ROTATE_LEFT + SPEED_NAME[ s ], speedMod[ s ] + "LEFT" );
-			behaviours.behaviour( new KeyRotate( -speed[ s ] ), ROTATE_RIGHT + SPEED_NAME[ s ], speedMod[ s ] + "RIGHT" );
-			behaviours.behaviour( new KeyZoom( speed[ s ] ), KEY_ZOOM_IN + SPEED_NAME[ s ], speedMod[ s ] + "UP" );
-			behaviours.behaviour( new KeyZoom( -speed[ s ] ), KEY_ZOOM_OUT + SPEED_NAME[ s ], speedMod[ s ] + "DOWN" );
-			behaviours.behaviour( new KeyTranslateZ( speed[ s ] ), KEY_FORWARD_Z + SPEED_NAME[ s ], speedMod[ s ] + "COMMA" );
-			behaviours.behaviour( new KeyTranslateZ( -speed[ s ] ), KEY_BACKWARD_Z + SPEED_NAME[ s ], speedMod[ s ] + "PERIOD" );
-		}
-	}
-
-	@Override
-	public void install( final TriggerBehaviourBindings bindings )
-	{
-		behaviours.install( bindings, "transform" );
-	}
-
-	@Override
-	public AffineTransform3D getTransform()
-	{
-		synchronized ( affine )
-		{
-			return affine.copy();
-		}
-	}
-
-	@Override
-	public void setTransform( final AffineTransform3D transform )
-	{
-		synchronized ( affine )
-		{
-			affine.set( transform );
-		}
-	}
-
-	@Override
-	public void setCanvasSize( final int width, final int height, final boolean updateTransform )
-	{
-		if ( width == 0 || height == 0 ) {
-			// NB: We are probably in some intermediate layout scenario.
-			// Attempting to trigger a transform update with 0 size will result
-			// in the exception "Matrix is singular" from imglib2-realtrasform.
-			return;
-		}
-		if ( updateTransform )
-		{
-			synchronized ( affine )
-			{
-				affine.set( affine.get( 0, 3 ) - canvasW / 2, 0, 3 );
-				affine.set( affine.get( 1, 3 ) - canvasH / 2, 1, 3 );
-				affine.scale( ( double ) width / canvasW );
-				affine.set( affine.get( 0, 3 ) + width / 2, 0, 3 );
-				affine.set( affine.get( 1, 3 ) + height / 2, 1, 3 );
-				notifyListener();
-			}
-		}
-		canvasW = width;
-		canvasH = height;
-		centerX = width / 2;
-		centerY = height / 2;
-	}
-
-	@Override
-	public void setTransformListener( final TransformListener< AffineTransform3D > transformListener )
-	{
-		listener = transformListener;
-	}
-
-	@Override
-	public String getHelpString()
-	{
-		return helpString;
-	}
-
-	/**
-	 * notifies {@link #listener} that the current transform changed.
-	 */
-	private void notifyListener()
-	{
-		if ( listener != null )
-			listener.transformChanged( affine );
-	}
-
-	/**
-	 * One step of rotation (radian).
-	 */
-	final protected static double step = Math.PI / 180;
-
-	final protected static String NL = System.getProperty( "line.separator" );
-
-	final protected static String helpString =
-			"Mouse control:" + NL + " " + NL +
-					"Pan and tilt the volume by left-click and dragging the image in the canvas, " + NL +
-					"move the volume by middle-or-right-click and dragging the image in the canvas, " + NL +
-					"browse alongside the z-axis using the mouse-wheel, and" + NL +
-					"zoom in and out using the mouse-wheel holding CTRL+SHIFT or META." + NL + " " + NL +
-					"Key control:" + NL + " " + NL +
-					"X - Select x-axis as rotation axis." + NL +
-					"Y - Select y-axis as rotation axis." + NL +
-					"Z - Select z-axis as rotation axis." + NL +
-					"CURSOR LEFT - Rotate clockwise around the choosen rotation axis." + NL +
-					"CURSOR RIGHT - Rotate counter-clockwise around the choosen rotation axis." + NL +
-					"CURSOR UP - Zoom in." + NL +
-					"CURSOR DOWN - Zoom out." + NL +
-					"./> - Forward alongside z-axis." + NL +
-					",/< - Backward alongside z-axis." + NL +
-					"SHIFT - Rotate and browse 10x faster." + NL +
-					"CTRL - Rotate and browse 10x slower.";
-
-	private void scale( final double s, final double x, final double y )
-	{
-		// center shift
-		affine.set( affine.get( 0, 3 ) - x, 0, 3 );
-		affine.set( affine.get( 1, 3 ) - y, 1, 3 );
-
-		// scale
-		affine.scale( s );
-
-		// center un-shift
-		affine.set( affine.get( 0, 3 ) + x, 0, 3 );
-		affine.set( affine.get( 1, 3 ) + y, 1, 3 );
-	}
-
-	/**
-	 * Rotate by d radians around axis. Keep screen coordinates (
-	 * {@link #centerX}, {@link #centerY}) fixed.
-	 */
-	private void rotate( final int axis, final double d )
-	{
-		// center shift
-		affine.set( affine.get( 0, 3 ) - centerX, 0, 3 );
-		affine.set( affine.get( 1, 3 ) - centerY, 1, 3 );
-
-		// rotate
-		affine.rotate( axis, d );
-
-		// center un-shift
-		affine.set( affine.get( 0, 3 ) + centerX, 0, 3 );
-		affine.set( affine.get( 1, 3 ) + centerY, 1, 3 );
-	}
-
-	private class Rotate implements DragBehaviour
-	{
-		private final double speed;
-
-		public Rotate( final double speed )
-		{
-			this.speed = speed;
-		}
-
-		@Override
-		public void init( final int x, final int y )
-		{
-			synchronized ( affine )
-			{
-				oX = x;
-				oY = y;
-				affineDragStart.set( affine );
-			}
-		}
-
-		@Override
-		public void drag( final int x, final int y )
-		{
-			synchronized ( affine )
-			{
-				final double dX = oX - x;
-				final double dY = oY - y;
-
-				affine.set( affineDragStart );
-
-				// center shift
-				affine.set( affine.get( 0, 3 ) - oX, 0, 3 );
-				affine.set( affine.get( 1, 3 ) - oY, 1, 3 );
-
-				final double v = step * speed;
-				affine.rotate( 0, -dY * v );
-				affine.rotate( 1, dX * v );
-
-				// center un-shift
-				affine.set( affine.get( 0, 3 ) + oX, 0, 3 );
-				affine.set( affine.get( 1, 3 ) + oY, 1, 3 );
-				notifyListener();
-			}
-		}
-
-		@Override
-		public void end( final int x, final int y )
-		{}
-	}
-
-	private class TranslateXY implements DragBehaviour
-	{
-		@Override
-		public void init( final int x, final int y )
-		{
-			synchronized ( affine )
-			{
-				oX = x;
-				oY = y;
-				affineDragStart.set( affine );
-			}
-		}
-
-		@Override
-		public void drag( final int x, final int y )
-		{
-			synchronized ( affine )
-			{
-				final double dX = oX - x;
-				final double dY = oY - y;
-
-				affine.set( affineDragStart );
-				affine.set( affine.get( 0, 3 ) - dX, 0, 3 );
-				affine.set( affine.get( 1, 3 ) - dY, 1, 3 );
-				notifyListener();
-			}
-		}
-
-		@Override
-		public void end( final int x, final int y )
-		{}
-	}
-
-	private class TranslateZ implements ScrollBehaviour
-	{
-		private final double speed;
-
-		public TranslateZ( final double speed )
-		{
-			this.speed = speed;
-		}
-
-		@Override
-		public void scroll( final double wheelRotation, final boolean isHorizontal, final int x, final int y )
-		{
-			synchronized ( affine )
-			{
-				final double dZ = speed * -wheelRotation;
-				// TODO (optionally) correct for zoom
-				affine.set( affine.get( 2, 3 ) - dZ, 2, 3 );
-				notifyListener();
-			}
-		}
-	}
-
-	private class Zoom implements ScrollBehaviour
-	{
-		private final double speed;
-
-		public Zoom( final double speed )
-		{
-			this.speed = speed;
-		}
-
-		@Override
-		public void scroll( final double wheelRotation, final boolean isHorizontal, final int x, final int y )
-		{
-			synchronized ( affine )
-			{
-				final double s = speed * wheelRotation;
-				final double dScale = 1.0 + 0.05;
-				if ( s > 0 )
-					scale( 1.0 / dScale, x, y );
-				else
-					scale( dScale, x, y );
-				notifyListener();
-			}
-		}
-	}
-
-	private class SelectRotationAxis implements ClickBehaviour
-	{
-		private final int axis;
-
-		public SelectRotationAxis( final int axis )
-		{
-			this.axis = axis;
-		}
-
-		@Override
-		public void click( final int x, final int y )
-		{
-			BehaviourTransformEventHandler3D.this.axis = axis;
-		}
-	}
-
-	private class KeyRotate implements ClickBehaviour
-	{
-		private final double speed;
-
-		public KeyRotate( final double speed )
-		{
-			this.speed = speed;
-		}
-
-		@Override
-		public void click( final int x, final int y )
-		{
-			synchronized ( affine )
-			{
-				rotate( axis, step * speed );
-				notifyListener();
-			}
-		}
-	}
-
-	private class KeyZoom implements ClickBehaviour
-	{
-		private final double dScale;
-
-		public KeyZoom( final double speed )
-		{
-			if ( speed > 0 )
-				dScale = 1.0 + 0.1 * speed;
-			else
-				dScale = 1.0 / ( 1.0 - 0.1 * speed );
-		}
-
-		@Override
-		public void click( final int x, final int y )
-		{
-			synchronized ( affine )
-			{
-				scale( dScale, centerX, centerY );
-				notifyListener();
-			}
-		}
-	}
-
-	private class KeyTranslateZ implements ClickBehaviour
-	{
-		private final double speed;
-
-		public KeyTranslateZ( final double speed )
-		{
-			this.speed = speed;
-		}
-
-		@Override
-		public void click( final int x, final int y )
-		{
-			synchronized ( affine )
-			{
-				affine.set( affine.get( 2, 3 ) + speed, 2, 3 );
-				notifyListener();
-			}
-		}
-	}
-}
diff --git a/src/main/java/bdv/BigDataViewer.java b/src/main/java/bdv/BigDataViewer.java
index e1f6a331043dccd8f8eb5a0db74af91b6d29fc45..2756ebff4497eb0a76a7500a94c50651bc83e13d 100644
--- a/src/main/java/bdv/BigDataViewer.java
+++ b/src/main/java/bdv/BigDataViewer.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -90,358 +89,390 @@ public class BigDataViewer {
 
     protected final Bookmarks bookmarks;
 
-    protected final BrightnessDialog brightnessDialog;
-
     protected final CropDialog cropDialog;
 
     protected final RecordMovieDialog movieDialog;
 
-    protected final RecordMaxProjectionDialog movieMaxProjectDialog;
-
-    protected final VisibilityAndGroupingDialog activeSourcesDialog;
-
-    protected final HelpDialog helpDialog;
-
-    protected final ManualTransformationEditor manualTransformationEditor;
-
-    protected final BookmarksEditor bookmarkEditor;
-
-    protected final JFileChooser fileChooser;
-
-    protected File proposedSettingsFile;
-
-    public void toggleManualTransformation() {
-        manualTransformationEditor.toggle();
-    }
-
-    public void initSetBookmark() {
-        bookmarkEditor.initSetBookmark();
-    }
-
-    public void initGoToBookmark() {
-        bookmarkEditor.initGoToBookmark();
-    }
-
-    public void initGoToBookmarkRotation() {
-        bookmarkEditor.initGoToBookmarkRotation();
-    }
-
-    private static String createSetupName(final BasicViewSetup setup) {
-        if (setup.hasName())
-            return setup.getName();
-
-        String name = "";
-
-        final Angle angle = setup.getAttribute(Angle.class);
-        if (angle != null)
-            name += (name.isEmpty() ? "" : " ") + "a " + angle.getName();
-
-        final Channel channel = setup.getAttribute(Channel.class);
-        if (channel != null)
-            name += (name.isEmpty() ? "" : " ") + "c " + channel.getName();
-
-        return name;
-    }
-
-    /**
-     * Create standard converter from the given {@code type} to ARGB:
-     * <ul>
-     * <li>For {@code RealType}s a {@link RealARGBColorConverter} is
-     * returned.</li>
-     * <li>For {@code ARGBType}s a {@link ScaledARGBConverter.ARGB} is
-     * returned.</li>
-     * <li>For {@code VolatileARGBType}s a
-     * {@link ScaledARGBConverter.VolatileARGB} is returned.</li>
-     * </ul>
-     */
-    @SuppressWarnings("unchecked")
-    public static <T extends NumericType<T>> Converter<T, ARGBType> createConverterToARGB(final T type) {
-        if (type instanceof RealType) {
-            final RealType<?> t = (RealType<?>) type;
-            final double typeMin = Math.max(0, Math.min(t.getMinValue(), 65535));
-            final double typeMax = Math.max(0, Math.min(t.getMaxValue(), 65535));
-            return (Converter<T, ARGBType>) RealARGBColorConverter.create(t, typeMin, typeMax);
-        } else if (type instanceof ARGBType)
-            return (Converter<T, ARGBType>) new ScaledARGBConverter.ARGB(0, 255);
-        else if (type instanceof VolatileARGBType)
-            return (Converter<T, ARGBType>) new ScaledARGBConverter.VolatileARGB(0, 255);
-        else
-            throw new IllegalArgumentException("ImgLoader of type " + type.getClass() + " not supported.");
-    }
-
-    /**
-     * Create a {@code ConverterSetup} for the given {@code SourceAndConverter}.
-     * {@link SourceAndConverter#asVolatile() Nested volatile}
-     * {@code SourceAndConverter} are added to the {@code ConverterSetup} if
-     * present. If {@code SourceAndConverter} does not comprise a
-     * {@code ColorConverter}, returns {@code null}.
-     *
-     * @param soc     {@code SourceAndConverter} for which to create a
-     *                {@code ConverterSetup}
-     * @param setupId setupId of the created {@code ConverterSetup}
-     * @return a new {@code ConverterSetup} or {@code null}
-     */
-    public static ConverterSetup createConverterSetup(final SourceAndConverter<?> soc, final int setupId) {
-        final List<ColorConverter> converters = new ArrayList<>();
-
-        final Converter<?, ARGBType> c = soc.getConverter();
-        if (c instanceof ColorConverter)
-            converters.add((ColorConverter) c);
-
-        final SourceAndConverter<? extends Volatile<?>> vsoc = soc.asVolatile();
-        if (vsoc != null) {
-            final Converter<?, ARGBType> vc = vsoc.getConverter();
-            if (vc instanceof ColorConverter)
-                converters.add((ColorConverter) vc);
-        }
-
-        if (converters.isEmpty())
-            return null;
-        else
-            return new RealARGBColorConverterSetup(setupId, converters);
-    }
-
-    /**
-     * Decorate source with an extra transformation, that can be edited manually
-     * in this viewer. {@link SourceAndConverter#asVolatile() Nested volatile}
-     * {@code SourceAndConverter} are wrapped as well, if present.
-     */
-    public static <T, V extends Volatile<T>> SourceAndConverter<T> wrapWithTransformedSource(final SourceAndConverter<T> soc) {
-        if (soc.asVolatile() == null)
-            return new SourceAndConverter<>(new TransformedSource<>(soc.getSpimSource()), soc.getConverter());
-
-        @SuppressWarnings("unchecked") final SourceAndConverter<V> vsoc = (SourceAndConverter<V>) soc.asVolatile();
-        final TransformedSource<T> ts = new TransformedSource<>(soc.getSpimSource());
-        final TransformedSource<V> vts = new TransformedSource<>(vsoc.getSpimSource(), ts);
-        return new SourceAndConverter<>(ts, soc.getConverter(), new SourceAndConverter<>(vts, vsoc.getConverter()));
-    }
-
-    private static <T extends NumericType<T>, V extends Volatile<T> & NumericType<V>> void initSetupNumericType(
-            final AbstractSpimData<?> spimData,
-            final BasicViewSetup setup,
-            final List<ConverterSetup> converterSetups,
-            final List<SourceAndConverter<?>> sources) {
-        final int setupId = setup.getId();
-        final ViewerImgLoader imgLoader = (ViewerImgLoader) spimData.getSequenceDescription().getImgLoader();
-        @SuppressWarnings("unchecked") final ViewerSetupImgLoader<T, V> setupImgLoader =
-                (ViewerSetupImgLoader<T, V>) imgLoader.getSetupImgLoader(
-                        setupId);
-        final T type = setupImgLoader.getImageType();
-        final V volatileType = setupImgLoader.getVolatileImageType();
-
-        if (!(type instanceof NumericType))
-            throw new IllegalArgumentException("ImgLoader of type " + type.getClass() + " not supported.");
-
-        final String setupName = createSetupName(setup);
-
-        SourceAndConverter<V> vsoc = null;
-        if (volatileType != null) {
-            final VolatileSpimSource<V> vs = new VolatileSpimSource<>(spimData, setupId, setupName);
-            vsoc = new SourceAndConverter<>(vs, createConverterToARGB(volatileType));
-        }
-
-        final SpimSource<T> s = new SpimSource<>(spimData, setupId, setupName);
-        final SourceAndConverter<T> soc = new SourceAndConverter<>(s, createConverterToARGB(type), vsoc);
-        final SourceAndConverter<T> tsoc = wrapWithTransformedSource(soc);
-        sources.add(tsoc);
-
-        final ConverterSetup converterSetup = createConverterSetup(tsoc, setupId);
-        if (converterSetup != null)
-            converterSetups.add(converterSetup);
-    }
-
-    public static void initSetups(
-            final AbstractSpimData<?> spimData,
-            final List<ConverterSetup> converterSetups,
-            final List<SourceAndConverter<?>> sources) {
-        for (final BasicViewSetup setup : spimData.getSequenceDescription().getViewSetupsOrdered())
-            initSetupNumericType(spimData, setup, converterSetups, sources);
-    }
-
-    /**
-     * @param converterSetups list of {@link ConverterSetup} that control min/max and color
-     *                        of sources.
-     * @param sources         list of pairs of source of some type and converter from that
-     *                        type to ARGB.
-     * @param spimData        may be null. The {@link AbstractSpimData} of the dataset (if
-     *                        there is one). If it exists, it is used to set up a "Crop"
-     *                        dialog.
-     * @param numTimepoints   the number of timepoints in the dataset.
-     * @param cache           handle to cache. This is used to control io timing.
-     * @param windowTitle     title of the viewer window.
-     * @param progressWriter  a {@link ProgressWriter} to which BDV may report progress
-     *                        (currently only used in the "Record Movie" dialog).
-     * @param options         optional parameters.
-     */
-    public BigDataViewer(
-            final ArrayList<ConverterSetup> converterSetups,
-            final ArrayList<SourceAndConverter<?>> sources,
-            final AbstractSpimData<?> spimData,
-            final int numTimepoints,
-            final CacheControl cache,
-            final String windowTitle,
-            final ProgressWriter progressWriter,
-            final ViewerOptions options) {
-        final InputTriggerConfig inputTriggerConfig = getInputTriggerConfig(options);
-        if (options.values.getTransformEventHandlerFactory() instanceof BehaviourTransformEventHandlerFactory)
-            ((BehaviourTransformEventHandlerFactory<?>) options.values.getTransformEventHandlerFactory()).setConfig(
-                    inputTriggerConfig);
-
-        viewerFrame = new ViewerFrame(sources, numTimepoints, cache, options);
-        if (windowTitle != null)
-            viewerFrame.setTitle(windowTitle);
-        viewer = viewerFrame.getViewerPanel();
-
-        for (final ConverterSetup cs : converterSetups)
-            cs.setViewer(viewer);
-
-        manualTransformation = new ManualTransformation(viewer);
-        manualTransformationEditor = new ManualTransformationEditor(viewer, viewerFrame.getKeybindings());
-
-        bookmarks = new Bookmarks();
-        bookmarkEditor = new BookmarksEditor(viewer, viewerFrame.getKeybindings(), bookmarks);
-
-        setupAssignments = new SetupAssignments(converterSetups, 0, 65535);
-        if (setupAssignments.getMinMaxGroups().size() > 0) {
-            final MinMaxGroup group = setupAssignments.getMinMaxGroups().get(0);
-            for (final ConverterSetup setup : setupAssignments.getConverterSetups())
-                setupAssignments.moveSetupToGroup(setup, group);
-        }
-
-        brightnessDialog = new BrightnessDialog(viewerFrame, setupAssignments);
-
-        if (spimData != null)
-            viewer.getSourceInfoOverlayRenderer().setTimePointsOrdered(spimData.getSequenceDescription().getTimePoints().getTimePointsOrdered());
-
-        cropDialog = (spimData == null) ? null : new CropDialog(viewerFrame, viewer, spimData.getSequenceDescription());
-
-        movieDialog = new RecordMovieDialog(viewerFrame, viewer, progressWriter);
-        // this is just to get updates of window size:
-        viewer.getDisplay().addOverlayRenderer(movieDialog);
-
-        movieMaxProjectDialog = new RecordMaxProjectionDialog(viewerFrame, viewer, progressWriter);
-        // this is just to get updates of window size:
-        viewer.getDisplay().addOverlayRenderer(movieMaxProjectDialog);
-
-        activeSourcesDialog = new VisibilityAndGroupingDialog(viewerFrame, viewer.getVisibilityAndGrouping());
-
-        helpDialog = new HelpDialog(viewerFrame);
-
-        fileChooser = new JFileChooser();
-        fileChooser.setFileFilter(new FileFilter() {
-            @Override
-            public String getDescription() {
-                return "xml files";
-            }
-
-            @Override
-            public boolean accept(final File f) {
-                if (f.isDirectory())
-                    return true;
-                if (f.isFile()) {
-                    final String s = f.getName();
-                    final int i = s.lastIndexOf('.');
-                    if (i > 0 && i < s.length() - 1) {
-                        final String ext = s.substring(i + 1).toLowerCase();
-                        return ext.equals("xml");
-                    }
-                }
-                return false;
-            }
-        });
-
-        NavigationActions.installActionBindings(viewerFrame.getKeybindings(), viewer, inputTriggerConfig);
-        BigDataViewerActions.installActionBindings(viewerFrame.getKeybindings(), this, inputTriggerConfig);
-
-        final JMenuBar menubar = new JMenuBar();
-        JMenu menu = new JMenu("File");
-        menubar.add(menu);
-
-        final ActionMap actionMap = viewerFrame.getKeybindings().getConcatenatedActionMap();
-        final JMenuItem miLoadSettings = new JMenuItem(actionMap.get(BigDataViewerActions.LOAD_SETTINGS));
-        miLoadSettings.setText("Load settings");
-        menu.add(miLoadSettings);
-
-        final JMenuItem miSaveSettings = new JMenuItem(actionMap.get(BigDataViewerActions.SAVE_SETTINGS));
-        miSaveSettings.setText("Save settings");
-        menu.add(miSaveSettings);
-
-        menu = new JMenu("Settings");
-        menubar.add(menu);
-
-        final JMenuItem miBrightness = new JMenuItem(actionMap.get(BigDataViewerActions.BRIGHTNESS_SETTINGS));
-        miBrightness.setText("Brightness & Color");
-        menu.add(miBrightness);
-
-        final JMenuItem miVisibility = new JMenuItem(actionMap.get(BigDataViewerActions.VISIBILITY_AND_GROUPING));
-        miVisibility.setText("Visibility & Grouping");
-        menu.add(miVisibility);
-
-        menu = new JMenu("Tools");
-        menubar.add(menu);
-
-        if (cropDialog != null) {
-            final JMenuItem miCrop = new JMenuItem(actionMap.get(BigDataViewerActions.CROP));
-            miCrop.setText("Crop");
-            menu.add(miCrop);
-        }
-
-        final JMenuItem miMovie = new JMenuItem(actionMap.get(BigDataViewerActions.RECORD_MOVIE));
-        miMovie.setText("Record Movie");
-        menu.add(miMovie);
-
-        final JMenuItem miMaxProjectMovie =
-                new JMenuItem(actionMap.get(BigDataViewerActions.RECORD_MAX_PROJECTION_MOVIE));
-        miMaxProjectMovie.setText("Record Max-Projection Movie");
-        menu.add(miMaxProjectMovie);
-
-        final JMenuItem miManualTransform = new JMenuItem(actionMap.get(BigDataViewerActions.MANUAL_TRANSFORM));
-        miManualTransform.setText("Manual Transform");
-        menu.add(miManualTransform);
-
-        menu = new JMenu("Help");
-        menubar.add(menu);
-
-        final JMenuItem miHelp = new JMenuItem(actionMap.get(BigDataViewerActions.SHOW_HELP));
-        miHelp.setText("Show Help");
-        menu.add(miHelp);
-
-        viewerFrame.setJMenuBar(menubar);
-    }
-
-    public static BigDataViewer open(final AbstractSpimData<?> spimData,
-                                     final String windowTitle,
-                                     final ProgressWriter progressWriter,
-                                     final ViewerOptions options) {
-        if (WrapBasicImgLoader.wrapImgLoaderIfNecessary(spimData)) {
-            System.err.println(
-                    "WARNING:\nOpening <SpimData> dataset that is not suited for interactive browsing.\nConsider " +
-                            "resaving as HDF5 for better performance.");
-        }
-
-        final ArrayList<ConverterSetup> converterSetups = new ArrayList<>();
-        final ArrayList<SourceAndConverter<?>> sources = new ArrayList<>();
-        initSetups(spimData, converterSetups, sources);
-
-        final AbstractSequenceDescription<?, ?, ?> seq = spimData.getSequenceDescription();
-        final int numTimepoints = seq.getTimePoints().size();
-        final CacheControl cache = ((ViewerImgLoader) seq.getImgLoader()).getCacheControl();
-
-        final BigDataViewer bdv = new BigDataViewer(converterSetups,
-                                                    sources,
-                                                    spimData,
-                                                    numTimepoints,
-                                                    cache,
-                                                    windowTitle,
-                                                    progressWriter,
-                                                    options);
-
-        WrapBasicImgLoader.removeWrapperIfPresent(spimData);
-
-        bdv.viewerFrame.setVisible(true);
-        InitializeViewerState.initTransform(bdv.viewer);
-        return bdv;
-    }
+	protected final BrightnessDialog brightnessDialog;
+
+	protected final RecordMaxProjectionDialog movieMaxProjectDialog;
+
+	protected final VisibilityAndGroupingDialog activeSourcesDialog;
+
+	protected final HelpDialog helpDialog;
+
+	protected final ManualTransformationEditor manualTransformationEditor;
+
+	protected final BookmarksEditor bookmarkEditor;
+
+	protected final JFileChooser fileChooser;
+
+	protected File proposedSettingsFile;
+
+	public void toggleManualTransformation()
+	{
+		manualTransformationEditor.toggle();
+	}
+
+	public void initSetBookmark()
+	{
+		bookmarkEditor.initSetBookmark();
+	}
+
+	public void initGoToBookmark()
+	{
+		bookmarkEditor.initGoToBookmark();
+	}
+
+	public void initGoToBookmarkRotation()
+	{
+		bookmarkEditor.initGoToBookmarkRotation();
+	}
+
+	private static String createSetupName( final BasicViewSetup setup )
+	{
+		if ( setup.hasName() )
+			return setup.getName();
+
+		String name = "";
+
+		final Angle angle = setup.getAttribute( Angle.class );
+		if ( angle != null )
+			name += ( name.isEmpty() ? "" : " " ) + "a " + angle.getName();
+
+		final Channel channel = setup.getAttribute( Channel.class );
+		if ( channel != null )
+			name += ( name.isEmpty() ? "" : " " ) + "c " + channel.getName();
+
+		return name;
+	}
+
+	/**
+	 * Create standard converter from the given {@code type} to ARGB:
+	 * <ul>
+	 * <li>For {@code RealType}s a {@link RealARGBColorConverter} is
+	 * returned.</li>
+	 * <li>For {@code ARGBType}s a {@link ScaledARGBConverter.ARGB} is
+	 * returned.</li>
+	 * <li>For {@code VolatileARGBType}s a
+	 * {@link ScaledARGBConverter.VolatileARGB} is returned.</li>
+	 * </ul>
+	 */
+	@SuppressWarnings( "unchecked" )
+	public static < T extends NumericType< T > > Converter< T, ARGBType > createConverterToARGB( final T type )
+	{
+		if ( type instanceof RealType )
+		{
+			final RealType< ? > t = ( RealType< ? > ) type;
+			final double typeMin = Math.max( 0, Math.min( t.getMinValue(), 65535 ) );
+			final double typeMax = Math.max( 0, Math.min( t.getMaxValue(), 65535 ) );
+			return ( Converter< T, ARGBType > ) RealARGBColorConverter.create( t, typeMin, typeMax );
+		}
+		else if ( type instanceof ARGBType )
+			return ( Converter< T, ARGBType > ) new ScaledARGBConverter.ARGB( 0, 255 );
+		else if ( type instanceof VolatileARGBType )
+			return ( Converter< T, ARGBType > ) new ScaledARGBConverter.VolatileARGB( 0, 255 );
+		else
+			throw new IllegalArgumentException( "ImgLoader of type " + type.getClass() + " not supported." );
+	}
+
+	/**
+	 * Create a {@code ConverterSetup} for the given {@code SourceAndConverter}.
+	 * {@link SourceAndConverter#asVolatile() Nested volatile}
+	 * {@code SourceAndConverter} are added to the {@code ConverterSetup} if
+	 * present. If {@code SourceAndConverter} does not comprise a
+	 * {@code ColorConverter}, returns {@code null}.
+	 *
+	 * @param soc
+	 *            {@code SourceAndConverter} for which to create a
+	 *            {@code ConverterSetup}
+	 * @param setupId
+	 *            setupId of the created {@code ConverterSetup}
+	 * @return a new {@code ConverterSetup} or {@code null}
+	 */
+	public static ConverterSetup createConverterSetup( final SourceAndConverter< ? > soc, final int setupId )
+	{
+		final List< ColorConverter > converters = new ArrayList<>();
+
+		final Converter< ?, ARGBType > c = soc.getConverter();
+		if ( c instanceof ColorConverter )
+			converters.add( ( ColorConverter ) c );
+
+		final SourceAndConverter< ? extends Volatile< ? > > vsoc = soc.asVolatile();
+		if ( vsoc != null )
+		{
+			final Converter< ?, ARGBType > vc = vsoc.getConverter();
+			if ( vc instanceof ColorConverter )
+				converters.add( ( ColorConverter ) vc );
+		}
+
+		if ( converters.isEmpty() )
+			return null;
+		else
+			return new RealARGBColorConverterSetup( setupId, converters );
+	}
+
+	/**
+	 * Decorate source with an extra transformation, that can be edited manually
+	 * in this viewer. {@link SourceAndConverter#asVolatile() Nested volatile}
+	 * {@code SourceAndConverter} are wrapped as well, if present.
+	 */
+	public static < T, V extends Volatile< T > > SourceAndConverter< T > wrapWithTransformedSource( final SourceAndConverter< T > soc )
+	{
+		if ( soc.asVolatile() == null )
+			return new SourceAndConverter<>( new TransformedSource<>( soc.getSpimSource() ), soc.getConverter() );
+
+		@SuppressWarnings( "unchecked" )
+		final SourceAndConverter< V > vsoc = ( SourceAndConverter< V > ) soc.asVolatile();
+		final TransformedSource< T > ts = new TransformedSource<>( soc.getSpimSource() );
+		final TransformedSource< V > vts = new TransformedSource<>( vsoc.getSpimSource(), ts );
+		return new SourceAndConverter<>( ts, soc.getConverter(), new SourceAndConverter<>( vts, vsoc.getConverter() ) );
+	}
+
+	private static < T extends NumericType< T >, V extends Volatile< T > & NumericType< V > > void initSetupNumericType(
+			final AbstractSpimData< ? > spimData,
+			final BasicViewSetup setup,
+			final List< ConverterSetup > converterSetups,
+			final List< SourceAndConverter< ? > > sources )
+	{
+		final int setupId = setup.getId();
+		final ViewerImgLoader imgLoader = ( ViewerImgLoader ) spimData.getSequenceDescription().getImgLoader();
+		@SuppressWarnings( "unchecked" )
+		final ViewerSetupImgLoader< T, V > setupImgLoader = ( ViewerSetupImgLoader< T, V > ) imgLoader.getSetupImgLoader( setupId );
+		final T type = setupImgLoader.getImageType();
+		final V volatileType = setupImgLoader.getVolatileImageType();
+
+		if ( ! ( type instanceof NumericType ) )
+			throw new IllegalArgumentException( "ImgLoader of type " + type.getClass() + " not supported." );
+
+		final String setupName = createSetupName( setup );
+
+		SourceAndConverter< V > vsoc = null;
+		if ( volatileType != null )
+		{
+			final VolatileSpimSource< V > vs = new VolatileSpimSource<>( spimData, setupId, setupName );
+			vsoc = new SourceAndConverter<>( vs, createConverterToARGB( volatileType ) );
+		}
+
+		final SpimSource< T > s = new SpimSource<>( spimData, setupId, setupName );
+		final SourceAndConverter< T > soc = new SourceAndConverter<>( s, createConverterToARGB( type ), vsoc );
+		final SourceAndConverter< T > tsoc = wrapWithTransformedSource( soc );
+		sources.add( tsoc );
+
+		final ConverterSetup converterSetup = createConverterSetup( tsoc, setupId );
+		if ( converterSetup != null )
+			converterSetups.add( converterSetup );
+	}
+
+	public static void initSetups(
+			final AbstractSpimData< ? > spimData,
+			final List< ConverterSetup > converterSetups,
+			final List< SourceAndConverter< ? > > sources )
+	{
+		for ( final BasicViewSetup setup : spimData.getSequenceDescription().getViewSetupsOrdered() )
+			initSetupNumericType( spimData, setup, converterSetups, sources );
+	}
+
+	/**
+	 *
+	 * @param converterSetups
+	 *            list of {@link ConverterSetup} that control min/max and color
+	 *            of sources.
+	 * @param sources
+	 *            list of pairs of source of some type and converter from that
+	 *            type to ARGB.
+	 * @param spimData
+	 *            may be null. The {@link AbstractSpimData} of the dataset (if
+	 *            there is one). If it exists, it is used to set up a "Crop"
+	 *            dialog.
+	 * @param numTimepoints
+	 *            the number of timepoints in the dataset.
+	 * @param cache
+	 *            handle to cache. This is used to control io timing.
+	 * @param windowTitle
+	 *            title of the viewer window.
+	 * @param progressWriter
+	 *            a {@link ProgressWriter} to which BDV may report progress
+	 *            (currently only used in the "Record Movie" dialog).
+	 * @param options
+	 *            optional parameters.
+	 */
+	public BigDataViewer(
+			final ArrayList< ConverterSetup > converterSetups,
+			final ArrayList< SourceAndConverter< ? > > sources,
+			final AbstractSpimData< ? > spimData,
+			final int numTimepoints,
+			final CacheControl cache,
+			final String windowTitle,
+			final ProgressWriter progressWriter,
+			final ViewerOptions options )
+	{
+		final InputTriggerConfig inputTriggerConfig = getInputTriggerConfig( options );
+
+		viewerFrame = new ViewerFrame( sources, numTimepoints, cache, options.inputTriggerConfig( inputTriggerConfig ) );
+		if ( windowTitle != null )
+			viewerFrame.setTitle( windowTitle );
+		viewer = viewerFrame.getViewerPanel();
+
+//		final ConverterSetup.SetupChangeListener requestRepaint = s -> viewer.requestRepaint();
+//		for ( final ConverterSetup cs : converterSetups )
+//			cs.setupChangeListeners().add( requestRepaint );
+
+		manualTransformation = new ManualTransformation( viewer );
+		manualTransformationEditor = new ManualTransformationEditor( viewer, viewerFrame.getKeybindings() );
+
+		bookmarks = new Bookmarks();
+		bookmarkEditor = new BookmarksEditor( viewer, viewerFrame.getKeybindings(), bookmarks );
+
+		final ConverterSetups setups = viewerFrame.getConverterSetups();
+		if ( converterSetups.size() != sources.size() )
+			System.err.println( "WARNING! Constructing BigDataViewer, with converterSetups.size() that is not the same as sources.size()." );
+		final int numSetups = Math.min( converterSetups.size(), sources.size() );
+		for ( int i = 0; i < numSetups; ++i )
+		{
+			final SourceAndConverter< ? > source = sources.get( i );
+			final ConverterSetup setup = converterSetups.get( i );
+			if ( setup != null )
+				setups.put( source, setup );
+		}
+
+		setupAssignments = new SetupAssignments( converterSetups, 0, 65535 );
+		if ( setupAssignments.getMinMaxGroups().size() > 0 )
+		{
+			final MinMaxGroup group = setupAssignments.getMinMaxGroups().get( 0 );
+			for ( final ConverterSetup setup : setupAssignments.getConverterSetups() )
+				setupAssignments.moveSetupToGroup( setup, group );
+		}
+
+		brightnessDialog = new BrightnessDialog( viewerFrame, setupAssignments );
+
+		if (spimData != null )
+			viewer.getSourceInfoOverlayRenderer().setTimePointsOrdered( spimData.getSequenceDescription().getTimePoints().getTimePointsOrdered() );
+
+		cropDialog = ( spimData == null ) ? null : new CropDialog( viewerFrame, viewer, spimData.getSequenceDescription() );
+
+		movieDialog = new RecordMovieDialog( viewerFrame, viewer, progressWriter );
+		// this is just to get updates of window size:
+		viewer.getDisplay().overlays().add( movieDialog );
+
+		movieMaxProjectDialog = new RecordMaxProjectionDialog( viewerFrame, viewer, progressWriter );
+		// this is just to get updates of window size:
+		viewer.getDisplay().overlays().add( movieMaxProjectDialog );
+
+		activeSourcesDialog = new VisibilityAndGroupingDialog( viewerFrame, viewer.state() );
+
+		helpDialog = new HelpDialog( viewerFrame );
+
+		fileChooser = new JFileChooser();
+		fileChooser.setFileFilter( new FileFilter()
+		{
+			@Override
+			public String getDescription()
+			{
+				return "xml files";
+			}
+
+			@Override
+			public boolean accept( final File f )
+			{
+				if ( f.isDirectory() )
+					return true;
+				if ( f.isFile() )
+				{
+					final String s = f.getName();
+					final int i = s.lastIndexOf( '.' );
+					if ( i > 0 && i < s.length() - 1 )
+					{
+						final String ext = s.substring( i + 1 ).toLowerCase();
+						return ext.equals( "xml" );
+					}
+				}
+				return false;
+			}
+		} );
+
+		NavigationActions.installActionBindings( viewerFrame.getKeybindings(), viewer, inputTriggerConfig );
+		BigDataViewerActions.installActionBindings( viewerFrame.getKeybindings(), this, inputTriggerConfig );
+
+		final JMenuBar menubar = new JMenuBar();
+		JMenu menu = new JMenu( "File" );
+		menubar.add( menu );
+
+		final ActionMap actionMap = viewerFrame.getKeybindings().getConcatenatedActionMap();
+		final JMenuItem miLoadSettings = new JMenuItem( actionMap.get( BigDataViewerActions.LOAD_SETTINGS ) );
+		miLoadSettings.setText( "Load settings" );
+		menu.add( miLoadSettings );
+
+		final JMenuItem miSaveSettings = new JMenuItem( actionMap.get( BigDataViewerActions.SAVE_SETTINGS ) );
+		miSaveSettings.setText( "Save settings" );
+		menu.add( miSaveSettings );
+
+		menu = new JMenu( "Settings" );
+		menubar.add( menu );
+
+		final JMenuItem miBrightness = new JMenuItem( actionMap.get( BigDataViewerActions.BRIGHTNESS_SETTINGS ) );
+		miBrightness.setText( "Brightness & Color" );
+		menu.add( miBrightness );
+
+		final JMenuItem miVisibility = new JMenuItem( actionMap.get( BigDataViewerActions.VISIBILITY_AND_GROUPING ) );
+		miVisibility.setText( "Visibility & Grouping" );
+		menu.add( miVisibility );
+
+		menu = new JMenu( "Tools" );
+		menubar.add( menu );
+
+		if ( cropDialog != null )
+		{
+			final JMenuItem miCrop = new JMenuItem( actionMap.get( BigDataViewerActions.CROP ) );
+			miCrop.setText( "Crop" );
+			menu.add( miCrop );
+		}
+
+		final JMenuItem miMovie = new JMenuItem( actionMap.get( BigDataViewerActions.RECORD_MOVIE ) );
+		miMovie.setText( "Record Movie" );
+		menu.add( miMovie );
+
+		final JMenuItem miMaxProjectMovie = new JMenuItem( actionMap.get( BigDataViewerActions.RECORD_MAX_PROJECTION_MOVIE ) );
+		miMaxProjectMovie.setText( "Record Max-Projection Movie" );
+		menu.add( miMaxProjectMovie );
+
+		final JMenuItem miManualTransform = new JMenuItem( actionMap.get( BigDataViewerActions.MANUAL_TRANSFORM ) );
+		miManualTransform.setText( "Manual Transform" );
+		menu.add( miManualTransform );
+
+		menu = new JMenu( "Help" );
+		menubar.add( menu );
+
+		final JMenuItem miHelp = new JMenuItem( actionMap.get( BigDataViewerActions.SHOW_HELP ) );
+		miHelp.setText( "Show Help" );
+		menu.add( miHelp );
+
+		viewerFrame.setJMenuBar( menubar );
+	}
+
+	public static BigDataViewer open( final AbstractSpimData< ? > spimData, final String windowTitle, final ProgressWriter progressWriter, final ViewerOptions options )
+	{
+		if ( WrapBasicImgLoader.wrapImgLoaderIfNecessary( spimData ) )
+		{
+			System.err.println( "WARNING:\nOpening <SpimData> dataset that is not suited for interactive browsing.\nConsider resaving as HDF5 for better performance." );
+		}
+
+		final ArrayList< ConverterSetup > converterSetups = new ArrayList<>();
+		final ArrayList< SourceAndConverter< ? > > sources = new ArrayList<>();
+		initSetups( spimData, converterSetups, sources );
+
+		final AbstractSequenceDescription< ?, ?, ? > seq = spimData.getSequenceDescription();
+		final int numTimepoints = seq.getTimePoints().size();
+		final CacheControl cache = ( ( ViewerImgLoader ) seq.getImgLoader() ).getCacheControl();
+
+		final BigDataViewer bdv = new BigDataViewer( converterSetups, sources, spimData, numTimepoints, cache, windowTitle, progressWriter, options );
+
+		WrapBasicImgLoader.removeWrapperIfPresent( spimData );
+
+		bdv.viewerFrame.setVisible( true );
+		InitializeViewerState.initTransform( bdv.viewer );
+		return bdv;
+	}
 
     public static BigDataViewer open(final String xmlFilename,
                                      final String windowTitle,
@@ -454,169 +485,227 @@ public class BigDataViewer {
         final SpimDataMinimal spimData = xmlIoSpimDataMinimal.load(xmlFilename);
         final BigDataViewer bdv = open(spimData, windowTitle, progressWriter, options);
         if (!bdv.tryLoadSettings(xmlFilename))
-            InitializeViewerState.initBrightness(0.001, 0.999, bdv.viewer, bdv.setupAssignments);
-        return bdv;
-    }
-
-    public static BigDataViewer open(
-            final ArrayList<ConverterSetup> converterSetups,
-            final ArrayList<SourceAndConverter<?>> sources,
-            final int numTimepoints,
-            final CacheControl cache,
-            final String windowTitle,
-            final ProgressWriter progressWriter,
-            final ViewerOptions options) {
-        final BigDataViewer bdv = new BigDataViewer(converterSetups,
-                                                    sources,
-                                                    null,
-                                                    numTimepoints,
-                                                    cache,
-                                                    windowTitle,
-                                                    progressWriter,
-                                                    options);
-        bdv.viewerFrame.setVisible(true);
-        InitializeViewerState.initTransform(bdv.viewer);
+            InitializeViewerState.initBrightness( 0.001, 0.999, bdv.viewerFrame );
         return bdv;
     }
 
-    public ViewerPanel getViewer() {
-        return viewer;
-    }
-
-    public ViewerFrame getViewerFrame() {
-        return viewerFrame;
-    }
-
-    public SetupAssignments getSetupAssignments() {
-        return setupAssignments;
-    }
-
-    public ManualTransformationEditor getManualTransformEditor() {
-        return manualTransformationEditor;
-    }
-
-    public boolean tryLoadSettings(final String xmlFilename) {
-        proposedSettingsFile = null;
-        if (xmlFilename.startsWith("http://")) {
-            // load settings.xml from the BigDataServer
-            final String settings = xmlFilename + "settings";
-            {
-                try {
-                    loadSettings(settings);
-                    return true;
-                } catch (final FileNotFoundException e) {
-                } catch (final Exception e) {
-                    e.printStackTrace();
-                }
-            }
-        } else if (xmlFilename.endsWith(".xml")) {
-            final String settings = xmlFilename.substring(0,
-                                                          xmlFilename.length() - ".xml".length()) + ".settings" +
-                    ".xml";
-            proposedSettingsFile = new File(settings);
-            if (proposedSettingsFile.isFile()) {
-                try {
-                    loadSettings(settings);
-                    return true;
-                } catch (final Exception e) {
-                    e.printStackTrace();
-                }
-            }
-        }
-        return false;
-    }
-
-    public void saveSettings() {
-        fileChooser.setSelectedFile(proposedSettingsFile);
-        final int returnVal = fileChooser.showSaveDialog(null);
-        if (returnVal == JFileChooser.APPROVE_OPTION) {
-            proposedSettingsFile = fileChooser.getSelectedFile();
-            try {
-                saveSettings(proposedSettingsFile.getCanonicalPath());
-            } catch (final IOException e) {
-                e.printStackTrace();
-            }
-        }
-    }
-
-    public void saveSettings(final String xmlFilename) throws IOException {
-        final Element root = new Element("Settings");
-        root.addContent(viewer.stateToXml());
-        root.addContent(setupAssignments.toXml());
-        root.addContent(manualTransformation.toXml());
-        root.addContent(bookmarks.toXml());
-        final Document doc = new Document(root);
-        final XMLOutputter xout = new XMLOutputter(Format.getPrettyFormat());
-        xout.output(doc, new FileWriter(xmlFilename));
-    }
-
-    /**
-     * If {@code options} doesn't define a {@link InputTriggerConfig}, try to
-     * load it from files in this order:
-     * <ol>
-     * <li>"bdvkeyconfig.yaml" in the current directory.
-     * <li>".bdv/bdvkeyconfig.yaml" in the user's home directory.
-     * <li>legacy "bigdataviewer.keys.properties" in current directory (will be
-     * also written to "bdvkeyconfig.yaml").
-     * </ol>
-     *
-     * @param options
-     * @return
-     */
-    public static InputTriggerConfig getInputTriggerConfig(final ViewerOptions options) {
-        InputTriggerConfig conf = options.values.getInputTriggerConfig();
-
-        // try "bdvkeyconfig.yaml" in current directory
-        if (conf == null && new File("bdvkeyconfig.yaml").isFile()) {
-            try {
-                conf = new InputTriggerConfig(YamlConfigIO.read("bdvkeyconfig.yaml"));
-            } catch (final IOException e) {
-            }
-        }
-
-        // try "~/.bdv/bdvkeyconfig.yaml"
-        if (conf == null) {
-            final String fn = System.getProperty("user.home") + "/.bdv/bdvkeyconfig.yaml";
-            if (new File(fn).isFile()) {
-                try {
-                    conf = new InputTriggerConfig(YamlConfigIO.read(fn));
-                } catch (final IOException e) {
-                }
-            }
-        }
-
-        if (conf == null) {
-            conf = new InputTriggerConfig();
-        }
-
-        return conf;
-    }
-
-    public void loadSettings() {
-        fileChooser.setSelectedFile(proposedSettingsFile);
-        final int returnVal = fileChooser.showOpenDialog(null);
-        if (returnVal == JFileChooser.APPROVE_OPTION) {
-            proposedSettingsFile = fileChooser.getSelectedFile();
-            try {
-                loadSettings(proposedSettingsFile.getCanonicalPath());
-            } catch (final Exception e) {
-                e.printStackTrace();
-            }
-        }
-    }
-
-    public void loadSettings(final String xmlFilename) throws IOException, JDOMException {
-        final SAXBuilder sax = new SAXBuilder();
-        final Document doc = sax.build(xmlFilename);
-        final Element root = doc.getRootElement();
-        viewer.stateFromXml(root);
-        setupAssignments.restoreFromXml(root);
-        manualTransformation.restoreFromXml(root);
-        bookmarks.restoreFromXml(root);
-        activeSourcesDialog.update();
-        viewer.requestRepaint();
-    }
-
+	public static BigDataViewer open( final String xmlFilename, final String windowTitle, final ProgressWriter progressWriter, final ViewerOptions options ) throws SpimDataException
+	{
+		final SpimDataMinimal spimData = new XmlIoSpimDataMinimal().load( xmlFilename );
+		final BigDataViewer bdv = open( spimData, windowTitle, progressWriter, options );
+		if ( !bdv.tryLoadSettings( xmlFilename ) )
+			InitializeViewerState.initBrightness( 0.001, 0.999, bdv.viewerFrame );
+		return bdv;
+	}
+
+	public static BigDataViewer open(
+			final ArrayList< ConverterSetup > converterSetups,
+			final ArrayList< SourceAndConverter< ? > > sources,
+			final int numTimepoints,
+			final CacheControl cache,
+			final String windowTitle,
+			final ProgressWriter progressWriter,
+			final ViewerOptions options )
+	{
+		final BigDataViewer bdv = new BigDataViewer( converterSetups, sources, null, numTimepoints, cache, windowTitle, progressWriter, options );
+		bdv.viewerFrame.setVisible( true );
+		InitializeViewerState.initTransform( bdv.viewer );
+		return bdv;
+	}
+
+	public ViewerPanel getViewer()
+	{
+		return viewer;
+	}
+
+	public ViewerFrame getViewerFrame()
+	{
+		return viewerFrame;
+	}
+
+	public ConverterSetups getConverterSetups()
+	{
+		return viewerFrame.getConverterSetups();
+	}
+
+	/**
+	 * @deprecated Instead {@code getViewer().state()} returns the {@link ViewerState} that can be modified directly.
+	 */
+	@Deprecated
+	public SetupAssignments getSetupAssignments()
+	{
+		return setupAssignments;
+	}
+
+	public ManualTransformationEditor getManualTransformEditor()
+	{
+		return manualTransformationEditor;
+	}
+
+	public boolean tryLoadSettings( final String xmlFilename )
+	{
+		proposedSettingsFile = null;
+		if( xmlFilename.startsWith( "http://" ) )
+		{
+			// load settings.xml from the BigDataServer
+			final String settings = xmlFilename + "settings";
+			{
+				try
+				{
+					loadSettings( settings );
+					return true;
+				}
+				catch ( final FileNotFoundException e )
+				{}
+				catch ( final Exception e )
+				{
+					e.printStackTrace();
+				}
+			}
+		}
+		else if ( xmlFilename.endsWith( ".xml" ) )
+		{
+			final String settings = xmlFilename.substring( 0, xmlFilename.length() - ".xml".length() ) + ".settings" + ".xml";
+			proposedSettingsFile = new File( settings );
+			if ( proposedSettingsFile.isFile() )
+			{
+				try
+				{
+					loadSettings( settings );
+					return true;
+				}
+				catch ( final Exception e )
+				{
+					e.printStackTrace();
+				}
+			}
+		}
+		return false;
+	}
+
+	public void saveSettings()
+	{
+		fileChooser.setSelectedFile( proposedSettingsFile );
+		final int returnVal = fileChooser.showSaveDialog( null );
+		if ( returnVal == JFileChooser.APPROVE_OPTION )
+		{
+			proposedSettingsFile = fileChooser.getSelectedFile();
+			try
+			{
+				saveSettings( proposedSettingsFile.getCanonicalPath() );
+			}
+			catch ( final IOException e )
+			{
+				e.printStackTrace();
+			}
+		}
+	}
+
+	public void saveSettings( final String xmlFilename ) throws IOException
+	{
+		final Element root = new Element( "Settings" );
+		root.addContent( viewer.stateToXml() );
+		root.addContent( setupAssignments.toXml() );
+		root.addContent( manualTransformation.toXml() );
+		root.addContent( bookmarks.toXml() );
+		final Document doc = new Document( root );
+		final XMLOutputter xout = new XMLOutputter( Format.getPrettyFormat() );
+		xout.output( doc, new FileWriter( xmlFilename ) );
+	}
+
+	/**
+	 * If {@code options} doesn't define a {@link InputTriggerConfig}, try to
+	 * load it from files in this order:
+	 * <ol>
+	 * <li>"bdvkeyconfig.yaml" in the current directory.
+	 * <li>".bdv/bdvkeyconfig.yaml" in the user's home directory.
+	 * <li>legacy "bigdataviewer.keys.properties" in current directory (will be
+	 * also written to "bdvkeyconfig.yaml").
+	 * </ol>
+	 *
+	 * @param options
+	 * @return
+	 */
+	public static InputTriggerConfig getInputTriggerConfig( final ViewerOptions options )
+	{
+		InputTriggerConfig conf = options.values.getInputTriggerConfig();
+
+		// try "bdvkeyconfig.yaml" in current directory
+		if ( conf == null && new File( "bdvkeyconfig.yaml" ).isFile() )
+		{
+			try
+			{
+				conf = new InputTriggerConfig( YamlConfigIO.read( "bdvkeyconfig.yaml" ) );
+			}
+			catch ( final IOException e )
+			{}
+		}
+
+		// try "~/.bdv/bdvkeyconfig.yaml"
+		if ( conf == null )
+		{
+			final String fn = System.getProperty( "user.home" ) + "/.bdv/bdvkeyconfig.yaml";
+			if ( new File( fn ).isFile() )
+			{
+				try
+				{
+					conf = new InputTriggerConfig( YamlConfigIO.read( fn ) );
+				}
+				catch ( final IOException e )
+				{}
+			}
+		}
+
+		if ( conf == null )
+		{
+			conf = new InputTriggerConfig();
+		}
+
+		return conf;
+	}
+
+	public void loadSettings()
+	{
+		fileChooser.setSelectedFile( proposedSettingsFile );
+		final int returnVal = fileChooser.showOpenDialog( null );
+		if ( returnVal == JFileChooser.APPROVE_OPTION )
+		{
+			proposedSettingsFile = fileChooser.getSelectedFile();
+			try
+			{
+				loadSettings( proposedSettingsFile.getCanonicalPath() );
+			}
+			catch ( final Exception e )
+			{
+				e.printStackTrace();
+			}
+		}
+	}
+
+	public void loadSettings( final String xmlFilename ) throws IOException, JDOMException
+	{
+		final SAXBuilder sax = new SAXBuilder();
+		final Document doc = sax.build( xmlFilename );
+		final Element root = doc.getRootElement();
+		viewer.stateFromXml( root );
+		setupAssignments.restoreFromXml( root );
+		manualTransformation.restoreFromXml( root );
+		bookmarks.restoreFromXml( root );
+		activeSourcesDialog.update();
+		viewer.requestRepaint();
+	}
+
+	public void expandAndFocusCardPanel()
+	{
+		viewerFrame.getSplitPanel().setCollapsed( false );
+		viewerFrame.getSplitPanel().getRightComponent().requestFocusInWindow();
+	}
+
+	public void collapseCardPanel()
+	{
+		viewerFrame.getSplitPanel().setCollapsed( true );
+		viewer.requestFocusInWindow();
+	}
 
     public static void main(final String[] args) {
         if (args.length < 1) {
diff --git a/src/main/java/bdv/BigDataViewerActions.java b/src/main/java/bdv/BigDataViewerActions.java
index 8119212a473f1bbdb232686c507acd8ddd112a5d..e4ce925e3a64990cb8a75f7fbab7c53ecc579480 100644
--- a/src/main/java/bdv/BigDataViewerActions.java
+++ b/src/main/java/bdv/BigDataViewerActions.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -57,6 +56,8 @@ public class BigDataViewerActions extends Actions
 	public static final String MANUAL_TRANSFORM = "toggle manual transformation";
 	public static final String SAVE_SETTINGS = "save settings";
 	public static final String LOAD_SETTINGS = "load settings";
+	public static final String EXPAND_CARDS = "expand and focus cards panel";
+	public static final String COLLAPSE_CARDS = "collapse cards panel";
 	public static final String RECORD_MOVIE = "record movie";
 	public static final String RECORD_MAX_PROJECTION_MOVIE = "record max projection movie";
 	public static final String SET_BOOKMARK = "set bookmark";
@@ -72,6 +73,8 @@ public class BigDataViewerActions extends Actions
 	static final String[] RECORD_MOVIE_KEYS                = new String[] { "F10" };
 	static final String[] SAVE_SETTINGS_KEYS               = new String[] { "F11" };
 	static final String[] LOAD_SETTINGS_KEYS               = new String[] { "F12" };
+	public static final String[] EXPAND_CARDS_KEYS         = new String[] { "P" };
+	public static final String[] COLLAPSE_CARDS_KEYS       = new String[] { "shift P", "shift ESCAPE" };
 	static final String[] GO_TO_BOOKMARK_KEYS              = new String[] { "B" };
 	static final String[] GO_TO_BOOKMARK_ROTATION_KEYS     = new String[] { "O" };
 	static final String[] SET_BOOKMARK_KEYS                = new String[] { "shift B" };
@@ -104,6 +107,8 @@ public class BigDataViewerActions extends Actions
 		actions.manualTransform( bdv.manualTransformationEditor );
 		actions.runnableAction( bdv::loadSettings, LOAD_SETTINGS, LOAD_SETTINGS_KEYS );
 		actions.runnableAction( bdv::saveSettings, SAVE_SETTINGS, SAVE_SETTINGS_KEYS );
+		actions.runnableAction( bdv::expandAndFocusCardPanel, EXPAND_CARDS, EXPAND_CARDS_KEYS );
+		actions.runnableAction( bdv::collapseCardPanel, COLLAPSE_CARDS, COLLAPSE_CARDS_KEYS );
 
 		actions.install( inputActionBindings, "bdv" );
 	}
@@ -119,11 +124,13 @@ public class BigDataViewerActions extends Actions
 		new ToggleDialogAction( name, dialog ).put( getActionMap() );
 	}
 
+	@Deprecated
 	public void dialog( final BrightnessDialog brightnessDialog )
 	{
 		toggleDialogAction( brightnessDialog, BRIGHTNESS_SETTINGS, BRIGHTNESS_SETTINGS_KEYS );
 	}
 
+	@Deprecated
 	public void dialog( final VisibilityAndGroupingDialog visibilityAndGroupingDialog )
 	{
 		toggleDialogAction( visibilityAndGroupingDialog, VISIBILITY_AND_GROUPING, VISIBILITY_AND_GROUPING_KEYS );
diff --git a/src/main/java/bdv/SpimSource.java b/src/main/java/bdv/SpimSource.java
index adfbcf79eb7a0cf693e0f813901c4ba773fcf9fc..2f2cb71a6722b13e131c4096767659ef797309e6 100644
--- a/src/main/java/bdv/SpimSource.java
+++ b/src/main/java/bdv/SpimSource.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/TransformEventHandler.java b/src/main/java/bdv/TransformEventHandler.java
new file mode 100644
index 0000000000000000000000000000000000000000..a2ee23f5b8ca0688c449e19498444957ab39d06e
--- /dev/null
+++ b/src/main/java/bdv/TransformEventHandler.java
@@ -0,0 +1,69 @@
+/*
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv;
+
+import org.scijava.ui.behaviour.util.Behaviours;
+
+/**
+ * Change a transformation in response to user input (mouse events, key events, etc.)
+ *
+ * The {@link TransformEventHandler} receives notifications about changes of the
+ * canvas size (it may react for example by changing the scale of the
+ * transformation accordingly).
+ *
+ * @author Tobias Pietzsch
+ */
+public interface TransformEventHandler
+{
+	/**
+	 * Install transformation behaviours into the specified {@code behaviours} contrainer.
+	 */
+	void install( Behaviours behaviours );
+
+	/**
+	 * This is called, when the screen size of the canvas (the component
+	 * displaying the image and generating mouse events) changes. This can be
+	 * used to determine screen coordinates to keep fixed while zooming or
+	 * rotating with the keyboard, e.g., set these to
+	 * <em>(width/2, height/2)</em>. It can also be used to update the current
+	 * source-to-screen transform, e.g., to change the zoom along with the
+	 * canvas size.
+	 *
+	 * @param width
+	 *            the new canvas width.
+	 * @param height
+	 *            the new canvas height.
+	 * @param updateTransform
+	 *            whether the current source-to-screen transform should be
+	 *            updated. This will be <code>false</code> for the initial
+	 *            update of a new {@link TransformEventHandler} and
+	 *            <code>true</code> on subsequent calls.
+	 */
+	void setCanvasSize( int width, int height, boolean updateTransform );
+}
diff --git a/src/main/java/bdv/TransformEventHandler2D.java b/src/main/java/bdv/TransformEventHandler2D.java
new file mode 100644
index 0000000000000000000000000000000000000000..667ed0d6388e661b1fdf592c2dc072c09d3b58b1
--- /dev/null
+++ b/src/main/java/bdv/TransformEventHandler2D.java
@@ -0,0 +1,463 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv;
+
+import net.imglib2.realtransform.AffineTransform3D;
+import org.scijava.ui.behaviour.Behaviour;
+import org.scijava.ui.behaviour.ClickBehaviour;
+import org.scijava.ui.behaviour.DragBehaviour;
+import org.scijava.ui.behaviour.ScrollBehaviour;
+import org.scijava.ui.behaviour.util.Behaviours;
+
+/**
+ * A {@link TransformEventHandler} that changes an {@link AffineTransform3D}
+ * through a set of {@link Behaviour}s.
+ *
+ * @author Tobias Pietzsch
+ */
+public class TransformEventHandler2D implements TransformEventHandler
+{
+	// -- behaviour names --
+
+	public static final String DRAG_TRANSLATE = "2d drag translate";
+	public static final String DRAG_ROTATE = "2d drag rotate";
+
+	public static final String ZOOM_NORMAL = "2d scroll zoom";
+	public static final String SCROLL_TRANSLATE = "2d scroll translate";
+	public static final String SCROLL_ROTATE = "2d scroll rotate";
+	public static final String ROTATE_LEFT = "2d rotate left";
+	public static final String ROTATE_RIGHT = "2d rotate right";
+	public static final String KEY_ZOOM_IN = "2d zoom in";
+	public static final String KEY_ZOOM_OUT = "2d zoom out";
+
+	public static final String ZOOM_FAST = "2d scroll zoom fast";
+	public static final String SCROLL_TRANSLATE_FAST = "2d scroll translate fast";
+	public static final String SCROLL_ROTATE_FAST = "2d scroll rotate fast";
+	public static final String ROTATE_LEFT_FAST = "2d rotate left fast";
+	public static final String ROTATE_RIGHT_FAST = "2d rotate right fast";
+	public static final String KEY_ZOOM_IN_FAST = "2d zoom in fast";
+	public static final String KEY_ZOOM_OUT_FAST = "2d zoom out fast";
+
+	public static final String ZOOM_SLOW = "2d scroll zoom slow";
+	public static final String SCROLL_TRANSLATE_SLOW = "2d scroll translate slow";
+	public static final String SCROLL_ROTATE_SLOW = "2d scroll rotate slow";
+	public static final String ROTATE_LEFT_SLOW = "2d rotate left slow";
+	public static final String ROTATE_RIGHT_SLOW = "2d rotate right slow";
+	public static final String KEY_ZOOM_IN_SLOW = "2d zoom in slow";
+	public static final String KEY_ZOOM_OUT_SLOW = "2d zoom out slow";
+
+	// -- default shortcuts --
+
+	private static final String[] DRAG_TRANSLATE_KEYS = new String[] { "button2", "button3" };
+	private static final String[] DRAG_ROTATE_KEYS = new String[] { "button1" };
+
+	private static final String[] ZOOM_NORMAL_KEYS = new String[] { "scroll", "meta scroll", "ctrl shift scroll" };
+	private static final String[] SCROLL_TRANSLATE_KEYS = new String[] { "not mapped" };
+	private static final String[] SCROLL_ROTATE_KEYS = new String[] { "not mapped" };
+	private static final String[] ROTATE_LEFT_KEYS = new String[] { "LEFT" };
+	private static final String[] ROTATE_RIGHT_KEYS = new String[] { "RIGHT" };
+	private static final String[] KEY_ZOOM_IN_KEYS = new String[] { "UP" };
+	private static final String[] KEY_ZOOM_OUT_KEYS = new String[] { "DOWN" };
+
+	private static final String[] ZOOM_FAST_KEYS = new String[] { "shift scroll" };
+	private static final String[] SCROLL_TRANSLATE_FAST_KEYS = new String[] { "not mapped" };
+	private static final String[] SCROLL_ROTATE_FAST_KEYS = new String[] { "not mapped" };
+	private static final String[] ROTATE_LEFT_FAST_KEYS = new String[] { "shift LEFT" };
+	private static final String[] ROTATE_RIGHT_FAST_KEYS = new String[] { "shift RIGHT" };
+	private static final String[] KEY_ZOOM_IN_FAST_KEYS = new String[] { "shift UP" };
+	private static final String[] KEY_ZOOM_OUT_FAST_KEYS = new String[] { "shift DOWN" };
+
+	private static final String[] ZOOM_SLOW_KEYS = new String[] { "ctrl scroll" };
+	private static final String[] SCROLL_TRANSLATE_SLOW_KEYS = new String[] { "not mapped" };
+	private static final String[] SCROLL_ROTATE_SLOW_KEYS = new String[] { "not mapped" };
+	private static final String[] ROTATE_LEFT_SLOW_KEYS = new String[] { "ctrl LEFT" };
+	private static final String[] ROTATE_RIGHT_SLOW_KEYS = new String[] { "ctrl RIGHT" };
+	private static final String[] KEY_ZOOM_IN_SLOW_KEYS = new String[] { "ctrl UP" };
+	private static final String[] KEY_ZOOM_OUT_SLOW_KEYS = new String[] { "ctrl DOWN" };
+
+	// -- behaviours --
+
+	private final DragTranslate dragTranslate;
+	private final DragRotate dragRotate;
+	private final Zoom zoom;
+	private final Zoom zoomFast;
+	private final Zoom zoomSlow;
+	private final ScrollTranslate scrollTranslate;
+	private final ScrollTranslate scrollTranslateFast;
+	private final ScrollTranslate scrollTranslateSlow;
+	private final ScrollRotate scrollRotate;
+	private final ScrollRotate scrollRotateFast;
+	private final ScrollRotate scrollRotateSlow;
+	private final KeyRotate keyRotateLeft;
+	private final KeyRotate keyRotateLeftFast;
+	private final KeyRotate keyRotateLeftSlow;
+	private final KeyRotate keyRotateRight;
+	private final KeyRotate keyRotateRightFast;
+	private final KeyRotate keyRotateRightSlow;
+	private final KeyZoom keyZoomIn;
+	private final KeyZoom keyZoomInFast;
+	private final KeyZoom keyZoomInSlow;
+	private final KeyZoom keyZoomOut;
+	private final KeyZoom keyZoomOutFast;
+	private final KeyZoom keyZoomOutSlow;
+
+	private static final double[] speed = { 1.0, 10.0, 0.1 };
+
+	/**
+	 * Copy of transform when mouse dragging started.
+	 */
+	private final AffineTransform3D affineDragStart = new AffineTransform3D();
+
+	/**
+	 * Current transform during mouse dragging.
+	 */
+	private final AffineTransform3D affineDragCurrent = new AffineTransform3D();
+
+	/**
+	 * Coordinates where mouse dragging started.
+	 */
+	private double oX, oY;
+
+	/**
+	 * The screen size of the canvas (the component displaying the image and
+	 * generating mouse events).
+	 */
+	private int canvasW = 1, canvasH = 1;
+
+	/**
+	 * Screen coordinates to keep centered while zooming or rotating with the
+	 * keyboard. These are set to <em>(canvasW/2, canvasH/2)</em>
+	 */
+	private int centerX = 0, centerY = 0;
+
+	private final TransformState transform;
+
+	public TransformEventHandler2D( final TransformState transform )
+	{
+		this.transform = transform;
+
+		dragTranslate = new DragTranslate();
+		dragRotate = new DragRotate();
+
+		scrollTranslate = new ScrollTranslate( speed[ 0 ] );
+		scrollTranslateFast = new ScrollTranslate( speed[ 1 ] );
+		scrollTranslateSlow = new ScrollTranslate( speed[ 2 ] );
+
+		zoom = new Zoom( speed[ 0 ] );
+		zoomFast = new Zoom( speed[ 1 ] );
+		zoomSlow = new Zoom( speed[ 2 ] );
+
+		scrollRotate = new ScrollRotate( 2 * speed[ 0 ] );
+		scrollRotateFast = new ScrollRotate( 2 * speed[ 1 ] );
+		scrollRotateSlow = new ScrollRotate( 2 * speed[ 2 ] );
+
+		keyRotateLeft = new KeyRotate( speed[ 0 ] );
+		keyRotateLeftFast = new KeyRotate( speed[ 1 ] );
+		keyRotateLeftSlow = new KeyRotate( speed[ 2 ] );
+		keyRotateRight = new KeyRotate( -speed[ 0 ] );
+		keyRotateRightFast = new KeyRotate( -speed[ 1 ] );
+		keyRotateRightSlow = new KeyRotate( -speed[ 2 ] );
+
+		keyZoomIn = new KeyZoom( speed[ 0 ] );
+		keyZoomInFast = new KeyZoom( speed[ 1 ] );
+		keyZoomInSlow = new KeyZoom( speed[ 2 ] );
+		keyZoomOut = new KeyZoom( -speed[ 0 ] );
+		keyZoomOutFast = new KeyZoom( -speed[ 1 ] );
+		keyZoomOutSlow = new KeyZoom( -speed[ 2 ] );
+	}
+
+	@Override
+	public void install( final Behaviours behaviours )
+	{
+		behaviours.behaviour( dragTranslate, DRAG_TRANSLATE, DRAG_TRANSLATE_KEYS );
+		behaviours.behaviour( dragRotate, DRAG_ROTATE,  DRAG_ROTATE_KEYS );
+
+		behaviours.behaviour( scrollTranslate, SCROLL_TRANSLATE, SCROLL_TRANSLATE_KEYS );
+		behaviours.behaviour( scrollTranslateFast, SCROLL_TRANSLATE_FAST, SCROLL_TRANSLATE_FAST_KEYS );
+		behaviours.behaviour( scrollTranslateSlow, SCROLL_TRANSLATE_SLOW, SCROLL_TRANSLATE_SLOW_KEYS );
+
+		behaviours.behaviour( zoom, ZOOM_NORMAL, ZOOM_NORMAL_KEYS );
+		behaviours.behaviour( zoomFast, ZOOM_FAST, ZOOM_FAST_KEYS );
+		behaviours.behaviour( zoomSlow, ZOOM_SLOW, ZOOM_SLOW_KEYS );
+
+		behaviours.behaviour( scrollRotate, SCROLL_ROTATE, SCROLL_ROTATE_KEYS );
+		behaviours.behaviour( scrollRotateFast, SCROLL_ROTATE_FAST, SCROLL_ROTATE_FAST_KEYS );
+		behaviours.behaviour( scrollRotateSlow, SCROLL_ROTATE_SLOW, SCROLL_ROTATE_SLOW_KEYS );
+
+		behaviours.behaviour( keyRotateLeft, ROTATE_LEFT, ROTATE_LEFT_KEYS );
+		behaviours.behaviour( keyRotateLeftFast, ROTATE_LEFT_FAST, ROTATE_LEFT_FAST_KEYS );
+		behaviours.behaviour( keyRotateLeftSlow, ROTATE_LEFT_SLOW, ROTATE_LEFT_SLOW_KEYS );
+		behaviours.behaviour( keyRotateRight, ROTATE_RIGHT, ROTATE_RIGHT_KEYS );
+		behaviours.behaviour( keyRotateRightFast, ROTATE_RIGHT_FAST, ROTATE_RIGHT_FAST_KEYS );
+		behaviours.behaviour( keyRotateRightSlow, ROTATE_RIGHT_SLOW, ROTATE_RIGHT_SLOW_KEYS );
+
+		behaviours.behaviour( keyZoomIn,  KEY_ZOOM_IN, KEY_ZOOM_IN_KEYS );
+		behaviours.behaviour( keyZoomInFast, KEY_ZOOM_IN_FAST, KEY_ZOOM_IN_FAST_KEYS );
+		behaviours.behaviour( keyZoomInSlow, KEY_ZOOM_IN_SLOW, KEY_ZOOM_IN_SLOW_KEYS );
+		behaviours.behaviour( keyZoomOut, KEY_ZOOM_OUT, KEY_ZOOM_OUT_KEYS );
+		behaviours.behaviour( keyZoomOutFast, KEY_ZOOM_OUT_FAST, KEY_ZOOM_OUT_FAST_KEYS );
+		behaviours.behaviour( keyZoomOutSlow, KEY_ZOOM_OUT_SLOW, KEY_ZOOM_OUT_SLOW_KEYS );
+	}
+
+	@Override
+	public void setCanvasSize( final int width, final int height, final boolean updateTransform )
+	{
+		if ( width == 0 || height == 0 ) {
+			// NB: We are probably in some intermediate layout scenario.
+			// Attempting to trigger a transform update with 0 size will result
+			// in the exception "Matrix is singular" from imglib2-realtrasform.
+			return;
+		}
+		if ( updateTransform )
+		{
+			final AffineTransform3D affine = transform.get();
+			affine.set( affine.get( 0, 3 ) - canvasW / 2, 0, 3 );
+			affine.set( affine.get( 1, 3 ) - canvasH / 2, 1, 3 );
+			affine.scale( ( double ) width / canvasW );
+			affine.set( affine.get( 0, 3 ) + width / 2, 0, 3 );
+			affine.set( affine.get( 1, 3 ) + height / 2, 1, 3 );
+			transform.set( affine );
+		}
+		canvasW = width;
+		canvasH = height;
+		centerX = width / 2;
+		centerY = height / 2;
+	}
+
+	/**
+	 * One step of rotation (radian).
+	 */
+	final private static double step = Math.PI / 180;
+
+	private void scale( final double s, final double x, final double y )
+	{
+		final AffineTransform3D affine = transform.get();
+
+		// center shift
+		affine.set( affine.get( 0, 3 ) - x, 0, 3 );
+		affine.set( affine.get( 1, 3 ) - y, 1, 3 );
+
+		// scale
+		affine.scale( s );
+
+		// center un-shift
+		affine.set( affine.get( 0, 3 ) + x, 0, 3 );
+		affine.set( affine.get( 1, 3 ) + y, 1, 3 );
+
+		transform.set( affine );
+	}
+
+	/**
+	 * Rotate by d radians around Z axis. Keep screen coordinates {@code (centerX, centerY)} fixed.
+	 */
+	private void rotate( final AffineTransform3D affine, final double d )
+	{
+		// center shift
+		affine.set( affine.get( 0, 3 ) - centerX, 0, 3 );
+		affine.set( affine.get( 1, 3 ) - centerY, 1, 3 );
+
+		// rotate
+		affine.rotate( 2, d );
+
+		// center un-shift
+		affine.set( affine.get( 0, 3 ) + centerX, 0, 3 );
+		affine.set( affine.get( 1, 3 ) + centerY, 1, 3 );
+	}
+
+	private class DragRotate implements DragBehaviour
+	{
+		@Override
+		public void init( final int x, final int y )
+		{
+			oX = x;
+			oY = y;
+			transform.get( affineDragStart );
+		}
+
+		@Override
+		public void drag( final int x, final int y )
+		{
+			final double dX = x - centerX;
+			final double dY = y - centerY;
+			final double odX = oX - centerX;
+			final double odY = oY - centerY;
+			final double theta = Math.atan2( dY, dX ) - Math.atan2( odY, odX );
+
+			affineDragCurrent.set( affineDragStart );
+			rotate( affineDragCurrent, theta );
+			transform.set( affineDragCurrent );
+		}
+
+		@Override
+		public void end( final int x, final int y )
+		{}
+	}
+
+	private class ScrollRotate implements ScrollBehaviour
+	{
+		private final double speed;
+
+		public ScrollRotate( final double speed )
+		{
+			this.speed = speed;
+		}
+
+		@Override
+		public void scroll( final double wheelRotation, final boolean isHorizontal, final int x, final int y )
+		{
+			final AffineTransform3D affine = transform.get();
+
+			final double theta = speed * wheelRotation * Math.PI / 180.0;
+
+			// center shift
+			affine.set( affine.get( 0, 3 ) - x, 0, 3 );
+			affine.set( affine.get( 1, 3 ) - y, 1, 3 );
+
+			affine.rotate( 2, theta );
+
+			// center un-shift
+			affine.set( affine.get( 0, 3 ) + x, 0, 3 );
+			affine.set( affine.get( 1, 3 ) + y, 1, 3 );
+
+			transform.set( affine );
+		}
+	}
+
+	private class DragTranslate implements DragBehaviour
+	{
+		@Override
+		public void init( final int x, final int y )
+		{
+			oX = x;
+			oY = y;
+			transform.get( affineDragStart );
+		}
+
+		@Override
+		public void drag( final int x, final int y )
+		{
+			final double dX = oX - x;
+			final double dY = oY - y;
+
+			affineDragCurrent.set( affineDragStart );
+			affineDragCurrent.set( affineDragCurrent.get( 0, 3 ) - dX, 0, 3 );
+			affineDragCurrent.set( affineDragCurrent.get( 1, 3 ) - dY, 1, 3 );
+
+			transform.set( affineDragCurrent );
+		}
+
+		@Override
+		public void end( final int x, final int y )
+		{}
+	}
+
+	private class ScrollTranslate implements ScrollBehaviour
+	{
+
+		private final double speed;
+
+		public ScrollTranslate( final double speed )
+		{
+			this.speed = speed;
+		}
+
+		@Override
+		public void scroll( final double wheelRotation, final boolean isHorizontal, final int x, final int y )
+		{
+			final AffineTransform3D affine = transform.get();
+
+			final double d = -wheelRotation * 10 * speed;
+			if ( isHorizontal )
+				affine.translate( d, 0, 0 );
+			else
+				affine.translate( 0, d, 0 );
+
+			transform.set( affine );
+		}
+	}
+
+	private class Zoom implements ScrollBehaviour
+	{
+
+		private final double speed;
+
+		public Zoom( final double speed )
+		{
+			this.speed = speed;
+		}
+
+		@Override
+		public void scroll( final double wheelRotation, final boolean isHorizontal, final int x, final int y )
+		{
+			final double s = speed * wheelRotation;
+			final double dScale = 1.0 + 0.05 * Math.abs( s );
+			if ( s > 0 )
+				scale( 1.0 / dScale, x, y );
+			else
+				scale( dScale, x, y );
+		}
+	}
+
+	private class KeyRotate implements ClickBehaviour
+	{
+		private final double speed;
+
+		public KeyRotate( final double speed )
+		{
+			this.speed = speed;
+		}
+
+		@Override
+		public void click( final int x, final int y )
+		{
+			final AffineTransform3D affine = transform.get();
+			rotate( affine, step * speed );
+			transform.set( affine );
+		}
+	}
+
+	private class KeyZoom implements ClickBehaviour
+	{
+		private final double dScale;
+
+		public KeyZoom( final double speed )
+		{
+			if ( speed > 0 )
+				dScale = 1.0 + 0.1 * speed;
+			else
+				dScale = 1.0 / ( 1.0 - 0.1 * speed );
+		}
+
+		@Override
+		public void click( final int x, final int y )
+		{
+			scale( dScale, centerX, centerY );
+		}
+	}
+}
diff --git a/src/main/java/bdv/TransformEventHandler3D.java b/src/main/java/bdv/TransformEventHandler3D.java
new file mode 100644
index 0000000000000000000000000000000000000000..9424e9509e703438ae07efbd83f2ae6990a8a21e
--- /dev/null
+++ b/src/main/java/bdv/TransformEventHandler3D.java
@@ -0,0 +1,515 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv;
+
+import net.imglib2.realtransform.AffineTransform3D;
+import org.scijava.ui.behaviour.Behaviour;
+import org.scijava.ui.behaviour.ClickBehaviour;
+import org.scijava.ui.behaviour.DragBehaviour;
+import org.scijava.ui.behaviour.ScrollBehaviour;
+import org.scijava.ui.behaviour.util.Behaviours;
+
+/**
+ * A {@link TransformEventHandler} that changes an {@link AffineTransform3D}
+ * through a set of {@link Behaviour}s.
+ *
+ * @author Stephan Saalfeld
+ * @author Tobias Pietzsch
+ */
+public class TransformEventHandler3D implements TransformEventHandler
+{
+	// -- behaviour names --
+
+	public static final String DRAG_TRANSLATE = "drag translate";
+	public static final String ZOOM_NORMAL = "scroll zoom";
+	public static final String SELECT_AXIS_X = "axis x";
+	public static final String SELECT_AXIS_Y = "axis y";
+	public static final String SELECT_AXIS_Z = "axis z";
+
+	public static final String DRAG_ROTATE = "drag rotate";
+	public static final String SCROLL_Z = "scroll browse z";
+	public static final String ROTATE_LEFT = "rotate left";
+	public static final String ROTATE_RIGHT = "rotate right";
+	public static final String KEY_ZOOM_IN = "zoom in";
+	public static final String KEY_ZOOM_OUT = "zoom out";
+	public static final String KEY_FORWARD_Z = "forward z";
+	public static final String KEY_BACKWARD_Z = "backward z";
+
+	public static final String DRAG_ROTATE_FAST = "drag rotate fast";
+	public static final String SCROLL_Z_FAST = "scroll browse z fast";
+	public static final String ROTATE_LEFT_FAST = "rotate left fast";
+	public static final String ROTATE_RIGHT_FAST = "rotate right fast";
+	public static final String KEY_ZOOM_IN_FAST = "zoom in fast";
+	public static final String KEY_ZOOM_OUT_FAST = "zoom out fast";
+	public static final String KEY_FORWARD_Z_FAST = "forward z fast";
+	public static final String KEY_BACKWARD_Z_FAST = "backward z fast";
+
+	public static final String DRAG_ROTATE_SLOW = "drag rotate slow";
+	public static final String SCROLL_Z_SLOW = "scroll browse z slow";
+	public static final String ROTATE_LEFT_SLOW = "rotate left slow";
+	public static final String ROTATE_RIGHT_SLOW = "rotate right slow";
+	public static final String KEY_ZOOM_IN_SLOW = "zoom in slow";
+	public static final String KEY_ZOOM_OUT_SLOW = "zoom out slow";
+	public static final String KEY_FORWARD_Z_SLOW = "forward z slow";
+	public static final String KEY_BACKWARD_Z_SLOW = "backward z slow";
+
+	// -- default shortcuts --
+
+	private static final String[] DRAG_TRANSLATE_KEYS = new String[] { "button2", "button3" };
+	private static final String[] ZOOM_NORMAL_KEYS = new String[] { "meta scroll", "ctrl shift scroll" };
+	private static final String[] SELECT_AXIS_X_KEYS = new String[] { "X" };
+	private static final String[] SELECT_AXIS_Y_KEYS = new String[] { "Y" };
+	private static final String[] SELECT_AXIS_Z_KEYS = new String[] { "Z" };
+
+	private static final String[] DRAG_ROTATE_KEYS = new String[] { "button1" };
+	private static final String[] SCROLL_Z_KEYS = new String[] { "scroll" };
+	private static final String[] ROTATE_LEFT_KEYS = new String[] { "LEFT" };
+	private static final String[] ROTATE_RIGHT_KEYS = new String[] { "RIGHT" };
+	private static final String[] KEY_ZOOM_IN_KEYS = new String[] { "UP" };
+	private static final String[] KEY_ZOOM_OUT_KEYS = new String[] { "DOWN" };
+	private static final String[] KEY_FORWARD_Z_KEYS = new String[] { "COMMA" };
+	private static final String[] KEY_BACKWARD_Z_KEYS = new String[] { "PERIOD" };
+
+	private static final String[] DRAG_ROTATE_FAST_KEYS = new String[] { "shift button1" };
+	private static final String[] SCROLL_Z_FAST_KEYS = new String[] { "shift scroll" };
+	private static final String[] ROTATE_LEFT_FAST_KEYS = new String[] { "shift LEFT" };
+	private static final String[] ROTATE_RIGHT_FAST_KEYS = new String[] { "shift RIGHT" };
+	private static final String[] KEY_ZOOM_IN_FAST_KEYS = new String[] { "shift UP" };
+	private static final String[] KEY_ZOOM_OUT_FAST_KEYS = new String[] { "shift DOWN" };
+	private static final String[] KEY_FORWARD_Z_FAST_KEYS = new String[] { "shift COMMA" };
+	private static final String[] KEY_BACKWARD_Z_FAST_KEYS = new String[] { "shift PERIOD" };
+
+	private static final String[] DRAG_ROTATE_SLOW_KEYS = new String[] { "ctrl button1" };
+	private static final String[] SCROLL_Z_SLOW_KEYS = new String[] { "ctrl scroll" };
+	private static final String[] ROTATE_LEFT_SLOW_KEYS = new String[] { "ctrl LEFT" };
+	private static final String[] ROTATE_RIGHT_SLOW_KEYS = new String[] { "ctrl RIGHT" };
+	private static final String[] KEY_ZOOM_IN_SLOW_KEYS = new String[] { "ctrl UP" };
+	private static final String[] KEY_ZOOM_OUT_SLOW_KEYS = new String[] { "ctrl DOWN" };
+	private static final String[] KEY_FORWARD_Z_SLOW_KEYS = new String[] { "ctrl COMMA" };
+	private static final String[] KEY_BACKWARD_Z_SLOW_KEYS = new String[] { "ctrl PERIOD" };
+
+	// -- behaviours --
+
+	private final TranslateXY dragTranslate;
+	private final Zoom zoom;
+	private final SelectRotationAxis selectRotationAxisX;
+	private final SelectRotationAxis selectRotationAxisY;
+	private final SelectRotationAxis selectRotationAxisZ;
+	private final Rotate dragRotate;
+	private final Rotate dragRotateFast;
+	private final Rotate dragRotateSlow;
+	private final TranslateZ translateZ;
+	private final TranslateZ translateZFast;
+	private final TranslateZ translateZSlow;
+	private final KeyRotate rotateLeft;
+	private final KeyRotate rotateLeftFast;
+	private final KeyRotate rotateLeftSlow;
+	private final KeyRotate rotateRight;
+	private final KeyRotate rotateRightFast;
+	private final KeyRotate rotateRightSlow;
+	private final KeyZoom keyZoomIn;
+	private final KeyZoom keyZoomInFast;
+	private final KeyZoom keyZoomInSlow;
+	private final KeyZoom keyZoomOut;
+	private final KeyZoom keyZoomOutFast;
+	private final KeyZoom keyZoomOutSlow;
+	private final KeyTranslateZ keyForwardZ;
+	private final KeyTranslateZ keyForwardZFast;
+	private final KeyTranslateZ keyForwardZSlow;
+	private final KeyTranslateZ keyBackwardZ;
+	private final KeyTranslateZ keyBackwardZFast;
+	private final KeyTranslateZ keyBackwardZSlow;
+
+	private static final double[] speed = { 1.0, 10.0, 0.1 };
+
+	/**
+	 * Copy of transform when mouse dragging started.
+	 */
+	private final AffineTransform3D affineDragStart = new AffineTransform3D();
+
+	/**
+	 * Current transform during mouse dragging.
+	 */
+	private final AffineTransform3D affineDragCurrent = new AffineTransform3D();
+
+	/**
+	 * Coordinates where mouse dragging started.
+	 */
+	private double oX, oY;
+
+	/**
+	 * Current rotation axis for rotating with keyboard, indexed {@code x->0, y->1,
+	 * z->2}.
+	 */
+	private int axis = 0;
+
+	/**
+	 * The screen size of the canvas (the component displaying the image and
+	 * generating mouse events).
+	 */
+	private int canvasW = 1, canvasH = 1;
+
+	/**
+	 * Screen coordinates to keep centered while zooming or rotating with the
+	 * keyboard. These are set to <em>(canvasW/2, canvasH/2)</em>
+	 */
+	private int centerX = 0, centerY = 0;
+
+	private final TransformState transform;
+
+	public TransformEventHandler3D( final TransformState transform )
+	{
+		this.transform = transform;
+
+		dragTranslate = new TranslateXY();
+		zoom = new Zoom();
+		selectRotationAxisX = new SelectRotationAxis( 0 );
+		selectRotationAxisY = new SelectRotationAxis( 1 );
+		selectRotationAxisZ = new SelectRotationAxis( 2 );
+
+		dragRotate = new Rotate( speed[ 0 ] );
+		dragRotateFast = new Rotate( speed[ 1 ] );
+		dragRotateSlow = new Rotate( speed[ 2 ] );
+
+		translateZ = new TranslateZ( speed[ 0 ] );
+		translateZFast = new TranslateZ( speed[ 1 ] );
+		translateZSlow = new TranslateZ( speed[ 2 ] );
+
+		rotateLeft = new KeyRotate( speed[ 0 ] );
+		rotateLeftFast = new KeyRotate( speed[ 1 ] );
+		rotateLeftSlow = new KeyRotate( speed[ 2 ] );
+		rotateRight = new KeyRotate( -speed[ 0 ] );
+		rotateRightFast = new KeyRotate( -speed[ 1 ] );
+		rotateRightSlow = new KeyRotate( -speed[ 2 ] );
+
+		keyZoomIn = new KeyZoom( speed[ 0 ] );
+		keyZoomInFast = new KeyZoom( speed[ 1 ] );
+		keyZoomInSlow = new KeyZoom( speed[ 2 ] );
+		keyZoomOut = new KeyZoom( -speed[ 0 ] );
+		keyZoomOutFast = new KeyZoom( -speed[ 1 ] );
+		keyZoomOutSlow = new KeyZoom( -speed[ 2 ] );
+
+		keyForwardZ = new KeyTranslateZ( speed[ 0 ] );
+		keyForwardZFast = new KeyTranslateZ( speed[ 1 ] );
+		keyForwardZSlow = new KeyTranslateZ( speed[ 2 ] );
+		keyBackwardZ = new KeyTranslateZ( -speed[ 0 ] );
+		keyBackwardZFast = new KeyTranslateZ( -speed[ 1 ] );
+		keyBackwardZSlow = new KeyTranslateZ( -speed[ 2 ] );
+	}
+
+	@Override
+	public void install( final Behaviours behaviours )
+	{
+		behaviours.behaviour( dragTranslate, DRAG_TRANSLATE, DRAG_TRANSLATE_KEYS );
+		behaviours.behaviour( zoom, ZOOM_NORMAL, ZOOM_NORMAL_KEYS );
+
+		behaviours.behaviour( selectRotationAxisX, SELECT_AXIS_X, SELECT_AXIS_X_KEYS );
+		behaviours.behaviour( selectRotationAxisY, SELECT_AXIS_Y, SELECT_AXIS_Y_KEYS );
+		behaviours.behaviour( selectRotationAxisZ, SELECT_AXIS_Z, SELECT_AXIS_Z_KEYS );
+
+		behaviours.behaviour( dragRotate, DRAG_ROTATE, DRAG_ROTATE_KEYS );
+		behaviours.behaviour( dragRotateFast, DRAG_ROTATE_FAST, DRAG_ROTATE_FAST_KEYS );
+		behaviours.behaviour( dragRotateSlow, DRAG_ROTATE_SLOW, DRAG_ROTATE_SLOW_KEYS );
+
+		behaviours.behaviour( translateZ, SCROLL_Z, SCROLL_Z_KEYS );
+		behaviours.behaviour( translateZFast, SCROLL_Z_FAST, SCROLL_Z_FAST_KEYS );
+		behaviours.behaviour( translateZSlow, SCROLL_Z_SLOW, SCROLL_Z_SLOW_KEYS );
+
+		behaviours.behaviour( rotateLeft, ROTATE_LEFT, ROTATE_LEFT_KEYS );
+		behaviours.behaviour( rotateLeftFast, ROTATE_LEFT_FAST, ROTATE_LEFT_FAST_KEYS );
+		behaviours.behaviour( rotateLeftSlow, ROTATE_LEFT_SLOW, ROTATE_LEFT_SLOW_KEYS );
+		behaviours.behaviour( rotateRight, ROTATE_RIGHT, ROTATE_RIGHT_KEYS );
+		behaviours.behaviour( rotateRightFast, ROTATE_RIGHT_FAST, ROTATE_RIGHT_FAST_KEYS );
+		behaviours.behaviour( rotateRightSlow, ROTATE_RIGHT_SLOW, ROTATE_RIGHT_SLOW_KEYS );
+
+		behaviours.behaviour( keyZoomIn, KEY_ZOOM_IN, KEY_ZOOM_IN_KEYS );
+		behaviours.behaviour( keyZoomInFast, KEY_ZOOM_IN_FAST, KEY_ZOOM_IN_FAST_KEYS );
+		behaviours.behaviour( keyZoomInSlow, KEY_ZOOM_IN_SLOW, KEY_ZOOM_IN_SLOW_KEYS );
+		behaviours.behaviour( keyZoomOut, KEY_ZOOM_OUT, KEY_ZOOM_OUT_KEYS );
+		behaviours.behaviour( keyZoomOutFast, KEY_ZOOM_OUT_FAST, KEY_ZOOM_OUT_FAST_KEYS );
+		behaviours.behaviour( keyZoomOutSlow, KEY_ZOOM_OUT_SLOW, KEY_ZOOM_OUT_SLOW_KEYS );
+
+		behaviours.behaviour( keyForwardZ, KEY_FORWARD_Z, KEY_FORWARD_Z_KEYS );
+		behaviours.behaviour( keyForwardZFast, KEY_FORWARD_Z_SLOW, KEY_FORWARD_Z_SLOW_KEYS );
+		behaviours.behaviour( keyForwardZSlow, KEY_FORWARD_Z_FAST, KEY_FORWARD_Z_FAST_KEYS );
+		behaviours.behaviour( keyBackwardZ, KEY_BACKWARD_Z, KEY_BACKWARD_Z_KEYS );
+		behaviours.behaviour( keyBackwardZFast, KEY_BACKWARD_Z_FAST, KEY_BACKWARD_Z_FAST_KEYS );
+		behaviours.behaviour( keyBackwardZSlow, KEY_BACKWARD_Z_SLOW, KEY_BACKWARD_Z_SLOW_KEYS );
+	}
+
+	@Override
+	public void setCanvasSize( final int width, final int height, final boolean updateTransform )
+	{
+		if ( width == 0 || height == 0 ) {
+			// NB: We are probably in some intermediate layout scenario.
+			// Attempting to trigger a transform update with 0 size will result
+			// in the exception "Matrix is singular" from imglib2-realtrasform.
+			return;
+		}
+		if ( updateTransform )
+		{
+			final AffineTransform3D affine = transform.get();
+			affine.set( affine.get( 0, 3 ) - canvasW / 2, 0, 3 );
+			affine.set( affine.get( 1, 3 ) - canvasH / 2, 1, 3 );
+			affine.scale( ( double ) width / canvasW );
+			affine.set( affine.get( 0, 3 ) + width / 2, 0, 3 );
+			affine.set( affine.get( 1, 3 ) + height / 2, 1, 3 );
+			transform.set( affine );
+		}
+		canvasW = width;
+		canvasH = height;
+		centerX = width / 2;
+		centerY = height / 2;
+	}
+
+	/**
+	 * One step of rotation (radian).
+	 */
+	final private static double step = Math.PI / 180;
+
+	private void scale( final double s, final double x, final double y )
+	{
+		final AffineTransform3D affine = transform.get();
+
+		// center shift
+		affine.set( affine.get( 0, 3 ) - x, 0, 3 );
+		affine.set( affine.get( 1, 3 ) - y, 1, 3 );
+
+		// scale
+		affine.scale( s );
+
+		// center un-shift
+		affine.set( affine.get( 0, 3 ) + x, 0, 3 );
+		affine.set( affine.get( 1, 3 ) + y, 1, 3 );
+
+		transform.set( affine );
+	}
+
+	/**
+	 * Rotate by d radians around axis. Keep screen coordinates (
+	 * {@link #centerX}, {@link #centerY}) fixed.
+	 */
+	private void rotate( final int axis, final double d )
+	{
+		final AffineTransform3D affine = transform.get();
+
+		// center shift
+		affine.set( affine.get( 0, 3 ) - centerX, 0, 3 );
+		affine.set( affine.get( 1, 3 ) - centerY, 1, 3 );
+
+		// rotate
+		affine.rotate( axis, d );
+
+		// center un-shift
+		affine.set( affine.get( 0, 3 ) + centerX, 0, 3 );
+		affine.set( affine.get( 1, 3 ) + centerY, 1, 3 );
+
+		transform.set( affine );
+	}
+
+	private class Rotate implements DragBehaviour
+	{
+		private final double speed;
+
+		public Rotate( final double speed )
+		{
+			this.speed = speed;
+		}
+
+		@Override
+		public void init( final int x, final int y )
+		{
+			oX = x;
+			oY = y;
+			transform.get( affineDragStart );
+		}
+
+		@Override
+		public void drag( final int x, final int y )
+		{
+			final double dX = oX - x;
+			final double dY = oY - y;
+
+			affineDragCurrent.set( affineDragStart );
+
+			// center shift
+			affineDragCurrent.set( affineDragCurrent.get( 0, 3 ) - oX, 0, 3 );
+			affineDragCurrent.set( affineDragCurrent.get( 1, 3 ) - oY, 1, 3 );
+
+			final double v = step * speed;
+			affineDragCurrent.rotate( 0, -dY * v );
+			affineDragCurrent.rotate( 1, dX * v );
+
+			// center un-shift
+			affineDragCurrent.set( affineDragCurrent.get( 0, 3 ) + oX, 0, 3 );
+			affineDragCurrent.set( affineDragCurrent.get( 1, 3 ) + oY, 1, 3 );
+
+			transform.set( affineDragCurrent );
+		}
+
+		@Override
+		public void end( final int x, final int y )
+		{}
+	}
+
+	private class TranslateXY implements DragBehaviour
+	{
+		@Override
+		public void init( final int x, final int y )
+		{
+			oX = x;
+			oY = y;
+			transform.get( affineDragStart );
+		}
+
+		@Override
+		public void drag( final int x, final int y )
+		{
+			final double dX = oX - x;
+			final double dY = oY - y;
+
+			affineDragCurrent.set( affineDragStart );
+			affineDragCurrent.set( affineDragCurrent.get( 0, 3 ) - dX, 0, 3 );
+			affineDragCurrent.set( affineDragCurrent.get( 1, 3 ) - dY, 1, 3 );
+
+			transform.set( affineDragCurrent );
+		}
+
+		@Override
+		public void end( final int x, final int y )
+		{}
+	}
+
+	private class TranslateZ implements ScrollBehaviour
+	{
+		private final double speed;
+
+		public TranslateZ( final double speed )
+		{
+			this.speed = speed;
+		}
+
+		@Override
+		public void scroll( final double wheelRotation, final boolean isHorizontal, final int x, final int y )
+		{
+			final AffineTransform3D affine = transform.get();
+
+			final double dZ = speed * -wheelRotation;
+			// TODO (optionally) correct for zoom
+			affine.set( affine.get( 2, 3 ) - dZ, 2, 3 );
+
+			transform.set( affine );
+		}
+	}
+
+	private class Zoom implements ScrollBehaviour
+	{
+		private final double speed = 1.0;
+
+		@Override
+		public void scroll( final double wheelRotation, final boolean isHorizontal, final int x, final int y )
+		{
+			final double s = speed * wheelRotation;
+			final double dScale = 1.0 + 0.05;
+			if ( s > 0 )
+				scale( 1.0 / dScale, x, y );
+			else
+				scale( dScale, x, y );
+		}
+	}
+
+	private class SelectRotationAxis implements ClickBehaviour
+	{
+		private final int axis;
+
+		public SelectRotationAxis( final int axis )
+		{
+			this.axis = axis;
+		}
+
+		@Override
+		public void click( final int x, final int y )
+		{
+			TransformEventHandler3D.this.axis = axis;
+		}
+	}
+
+	private class KeyRotate implements ClickBehaviour
+	{
+		private final double speed;
+
+		public KeyRotate( final double speed )
+		{
+			this.speed = speed;
+		}
+
+		@Override
+		public void click( final int x, final int y )
+		{
+			rotate( axis, step * speed );
+		}
+	}
+
+	private class KeyZoom implements ClickBehaviour
+	{
+		private final double dScale;
+
+		public KeyZoom( final double speed )
+		{
+			if ( speed > 0 )
+				dScale = 1.0 + 0.1 * speed;
+			else
+				dScale = 1.0 / ( 1.0 - 0.1 * speed );
+		}
+
+		@Override
+		public void click( final int x, final int y )
+		{
+			scale( dScale, centerX, centerY );
+		}
+	}
+
+	private class KeyTranslateZ implements ClickBehaviour
+	{
+		private final double speed;
+
+		public KeyTranslateZ( final double speed )
+		{
+			this.speed = speed;
+		}
+
+		@Override
+		public void click( final int x, final int y )
+		{
+			final AffineTransform3D affine = transform.get();
+			affine.set( affine.get( 2, 3 ) + speed, 2, 3 );
+			transform.set( affine );
+		}
+	}
+}
diff --git a/src/main/java/bdv/BehaviourTransformEventHandler.java b/src/main/java/bdv/TransformEventHandlerFactory.java
similarity index 73%
rename from src/main/java/bdv/BehaviourTransformEventHandler.java
rename to src/main/java/bdv/TransformEventHandlerFactory.java
index 4abf1c09d7200011dfd9f2f8da3a3b3393ff2d43..3e03753687f1c2a3b869390361668af9ccb8f8a5 100644
--- a/src/main/java/bdv/BehaviourTransformEventHandler.java
+++ b/src/main/java/bdv/TransformEventHandlerFactory.java
@@ -1,9 +1,8 @@
-/*-
+/*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -29,11 +28,15 @@
  */
 package bdv;
 
-import org.scijava.ui.behaviour.util.TriggerBehaviourBindings;
-
-import net.imglib2.ui.TransformEventHandler;
-
-public interface BehaviourTransformEventHandler< A > extends TransformEventHandler< A >
+/**
+ * Factory for {@code TransformEventHandler}.
+ *
+ * @author Tobias Pietzsch
+ */
+public interface TransformEventHandlerFactory
 {
-	public void install( final TriggerBehaviourBindings bindings );
+	/**
+	 * Create a new {@code TransformEventHandler}.
+	 */
+	TransformEventHandler create( TransformState transformState );
 }
diff --git a/src/main/java/bdv/viewer/render/TransformAwareRenderTarget.java b/src/main/java/bdv/TransformState.java
similarity index 57%
rename from src/main/java/bdv/viewer/render/TransformAwareRenderTarget.java
rename to src/main/java/bdv/TransformState.java
index ac381770164bc3513896f71b170ee2fb886931b9..a73ae03f59fda1ae7401350356e3ba91b231db51 100644
--- a/src/main/java/bdv/viewer/render/TransformAwareRenderTarget.java
+++ b/src/main/java/bdv/TransformState.java
@@ -1,9 +1,8 @@
-/*
+/*-
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -27,28 +26,53 @@
  * POSSIBILITY OF SUCH DAMAGE.
  * #L%
  */
-package bdv.viewer.render;
-
-import java.awt.image.BufferedImage;
+package bdv;
 
+import java.util.function.Consumer;
 import net.imglib2.realtransform.AffineTransform3D;
-import net.imglib2.ui.RenderTarget;
-import net.imglib2.ui.TransformListener;
 
-public interface TransformAwareRenderTarget extends RenderTarget
+public interface TransformState
 {
 	/**
-	 * Set the {@link BufferedImage} that is to be drawn on the canvas, and the
-	 * transform with which this image was created.
+	 * Get the current transform.
 	 *
-	 * @param img
-	 *            image to draw (may be null).
+	 * @param transform
+	 *     is set to the current transform
 	 */
-	public BufferedImage setBufferedImageAndTransform( final BufferedImage img, final AffineTransform3D transform );
+	void get( AffineTransform3D transform );
 
-	public void addTransformListener( final TransformListener< AffineTransform3D > listener );
+	/**
+	 * Get the current transform.
+	 *
+	 * @return a copy of the current transform
+	 */
+	default AffineTransform3D get()
+	{
+		final AffineTransform3D transform = new AffineTransform3D();
+		get( transform );
+		return transform;
+	}
+
+	/**
+	 * Set the transform.
+	 */
+	void set( AffineTransform3D transform );
 
-	public void addTransformListener( final TransformListener< AffineTransform3D > listener, final int index );
+	static TransformState from( Consumer< AffineTransform3D > get, Consumer< AffineTransform3D > set )
+	{
+		return new TransformState()
+		{
+			@Override
+			public void get( final AffineTransform3D transform )
+			{
+				get.accept( transform );
+			}
 
-	public void removeTransformListener( final TransformListener< AffineTransform3D > listener );
+			@Override
+			public void set( final AffineTransform3D transform )
+			{
+				set.accept( transform );
+			}
+		};
+	}
 }
diff --git a/src/main/java/bdv/ViewerImgLoader.java b/src/main/java/bdv/ViewerImgLoader.java
index dc6f76eef0d4cdb386d7d1b7daadb793e6d228dc..e398116b5160b8b078efe2ed5efeb0186cca8825 100644
--- a/src/main/java/bdv/ViewerImgLoader.java
+++ b/src/main/java/bdv/ViewerImgLoader.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -35,7 +34,7 @@ import mpicbg.spim.data.generic.sequence.BasicMultiResolutionImgLoader;
 public interface ViewerImgLoader extends BasicMultiResolutionImgLoader
 {
 	@Override
-	public ViewerSetupImgLoader< ?, ? > getSetupImgLoader( final int setupId );
+	ViewerSetupImgLoader< ?, ? > getSetupImgLoader( final int setupId );
 
-	public CacheControl getCacheControl();
+	CacheControl getCacheControl();
 }
diff --git a/src/main/java/bdv/ViewerSetupImgLoader.java b/src/main/java/bdv/ViewerSetupImgLoader.java
index 3566132262bef633ee683fbe628e6f3ca5057e35..193749c992552636bd52b11caf92402a96b3a163 100644
--- a/src/main/java/bdv/ViewerSetupImgLoader.java
+++ b/src/main/java/bdv/ViewerSetupImgLoader.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -36,7 +35,7 @@ import net.imglib2.Volatile;
 
 public interface ViewerSetupImgLoader< T, V extends Volatile< T > > extends BasicMultiResolutionSetupImgLoader< T >
 {
-	public RandomAccessibleInterval< V > getVolatileImage( final int timepointId, final int level, ImgLoaderHint... hints );
+	RandomAccessibleInterval< V > getVolatileImage( final int timepointId, final int level, ImgLoaderHint... hints );
 
-	public V getVolatileImageType();
+	V getVolatileImageType();
 }
diff --git a/src/main/java/bdv/VolatileSpimSource.java b/src/main/java/bdv/VolatileSpimSource.java
index 4b7a245ad4e97da7ab0de68d162a4a1a727d8a18..9abd82c30405b1b66ec5759b1fdf70e3334c3d51 100644
--- a/src/main/java/bdv/VolatileSpimSource.java
+++ b/src/main/java/bdv/VolatileSpimSource.java
@@ -1,19 +1,18 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
- *
+ * 
  * 1. Redistributions of source code must retain the above copyright notice,
  *    this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
  *    this list of conditions and the following disclaimer in the documentation
  *    and/or other materials provided with the distribution.
- *
+ * 
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
diff --git a/src/main/java/bdv/cache/CacheControl.java b/src/main/java/bdv/cache/CacheControl.java
index 0a6ee06dadcbfa1043b5d9aad392578d27e2767c..a4b96910dcb34ad527c5cfb57ab2ed629bfaa734 100644
--- a/src/main/java/bdv/cache/CacheControl.java
+++ b/src/main/java/bdv/cache/CacheControl.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -40,7 +39,7 @@ import bdv.img.cache.VolatileGlobalCellCache;
  * {@link VolatileGlobalCellCache}, these can be simply implemented to do
  * nothing.
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
 public interface CacheControl
 {
@@ -54,12 +53,12 @@ public interface CacheControl
 	 * previously enqueued requests to be enqueued again for the new frame.
 	 * </ul>
 	 */
-	public void prepareNextFrame();
+	void prepareNextFrame();
 
 	/**
 	 * {@link CacheControl} that does nothing.
 	 */
-	public static class Dummy implements CacheControl
+	class Dummy implements CacheControl
 	{
 		@Override
 		public void prepareNextFrame()
@@ -70,7 +69,7 @@ public interface CacheControl
 	 * {@link CacheControl} backed by a set of {@link CacheControl}s.
 	 * {@link #prepareNextFrame()} forwards to all of them.
 	 */
-	public static class CacheControls implements CacheControl
+	class CacheControls implements CacheControl
 	{
 		private final CopyOnWriteArrayList< CacheControl > cacheControls = new CopyOnWriteArrayList<>();
 
@@ -94,6 +93,11 @@ public interface CacheControl
 			cacheControls.remove( cacheControl );
 		}
 
+		public synchronized void clear()
+		{
+			cacheControls.clear();
+		}
+
 		@Override
 		public void prepareNextFrame()
 		{
diff --git a/src/main/java/bdv/export/CopyBlock.java b/src/main/java/bdv/export/CopyBlock.java
new file mode 100644
index 0000000000000000000000000000000000000000..2171fc21cce4cc4b18b829bbaed92b1a1a2c78dc
--- /dev/null
+++ b/src/main/java/bdv/export/CopyBlock.java
@@ -0,0 +1,147 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.export;
+
+import java.util.Arrays;
+import net.imglib2.RandomAccess;
+import net.imglib2.loops.ClassCopyProvider;
+import net.imglib2.type.numeric.RealType;
+
+public interface CopyBlock< T extends RealType< T > >
+{
+	void copyBlock( final RandomAccess< T > in, final RandomAccess< T > out, final int[] dimensions );
+
+	static < T extends RealType< T > > CopyBlock< T > create(
+			final int numDimensions,
+			final Class< ?  > pixelTypeClass,
+			final Class< ? > inAccessClass )
+	{
+		return CopyBlockInstances.create( numDimensions, pixelTypeClass, inAccessClass );
+	}
+}
+
+class CopyBlockInstances
+{
+	@SuppressWarnings( "rawtypes" )
+	private static ClassCopyProvider< CopyBlock > provider;
+
+	@SuppressWarnings( "unchecked" )
+	public static < T extends RealType< T > > CopyBlock< T > create(
+			final int numDimensions,
+			final Class< ?  > pixelTypeClass,
+			final Class< ? > inAccessClass )
+	{
+		if ( provider == null )
+		{
+			synchronized ( CopyBlockInstances.class )
+			{
+				if ( provider == null )
+					provider = new ClassCopyProvider<>( Imp.class, CopyBlock.class, int.class );
+			}
+		}
+
+		Object key = Arrays.asList( numDimensions, pixelTypeClass, inAccessClass );
+		return provider.newInstanceForKey( key, numDimensions );
+	}
+
+	public static class Imp< T extends RealType< T > > implements CopyBlock< T >
+	{
+		private final int n;
+
+		public Imp( final int n )
+		{
+			if ( n < 1 || n > 3 )
+				throw new IllegalArgumentException();
+
+			this.n = n;
+		}
+
+		@Override
+		public void copyBlock(
+				final RandomAccess< T > in,
+				final RandomAccess< T > out,
+				final int[] dimensions )
+		{
+			if ( n == 3 )
+				copyBlock3D( out, dimensions[ 0 ], dimensions[ 1 ], dimensions[ 2 ], in );
+			else if ( n == 2 )
+				copyBlock2D( out, dimensions[ 0 ], dimensions[ 1 ], in );
+			else
+				copyBlock1D( out, dimensions[ 0 ], in );
+		}
+
+		private void copyBlock3D(
+				final RandomAccess< T > out,
+				final int sx, // size of output image
+				final int sy,
+				final int sz,
+				final RandomAccess< T > in )
+		{
+			for ( int z = 0; z < sz; ++z )
+			{
+				copyBlock2D( out, sx, sy, in );
+				out.fwd( 2 );
+				in.fwd( 2 );
+			}
+			out.move( -sz, 2 );
+			in.move( -sz, 2 );
+		}
+
+		private void copyBlock2D(
+				final RandomAccess< T > out,
+				final int sx, // size of output image
+				final int sy,
+				final RandomAccess< T > in )
+		{
+			for ( int y = 0; y < sy; ++y )
+			{
+				copyBlock1D( out, sx, in );
+				out.fwd( 1 );
+				in.fwd( 1 );
+			}
+			out.move( -sy, 1 );
+			in.move( -sy, 1 );
+		}
+
+		private void copyBlock1D(
+				final RandomAccess< T > out,
+				final int sx, // size of output image
+				final RandomAccess< T > in )
+		{
+			for ( int x = 0; x < sx; ++x )
+			{
+				out.get().set( in.get() );
+				out.fwd( 0 );
+				in.fwd( 0 );
+			}
+			out.move( -sx, 0 );
+			in.move( -sx, 0 );
+		}
+	}
+}
diff --git a/src/main/java/bdv/export/Downsample.java b/src/main/java/bdv/export/Downsample.java
index 5d270db0f34e1d5d7c6c39eeefdea81e9fb29894..8ddc2dda3bc74d34e7fe7a484feb46ceb40d2392 100644
--- a/src/main/java/bdv/export/Downsample.java
+++ b/src/main/java/bdv/export/Downsample.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -44,6 +43,9 @@ import net.imglib2.view.Views;
 
 public class Downsample
 {
+	/**
+	 * TODO: Revise. This is probably not very efficient
+	 */
 	public static < T extends RealType< T > > void downsample( final RandomAccessible< T > input, final RandomAccessibleInterval< T > output, final int[] factor )
 	{
 		assert input.numDimensions() == output.numDimensions();
diff --git a/src/main/java/bdv/export/DownsampleBlock.java b/src/main/java/bdv/export/DownsampleBlock.java
new file mode 100644
index 0000000000000000000000000000000000000000..ce07e3e8e7952244b65ce5876deef25d5816d407
--- /dev/null
+++ b/src/main/java/bdv/export/DownsampleBlock.java
@@ -0,0 +1,250 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.export;
+
+import java.util.Arrays;
+import net.imglib2.Cursor;
+import net.imglib2.RandomAccess;
+import net.imglib2.img.array.ArrayImgs;
+import net.imglib2.loops.ClassCopyProvider;
+import net.imglib2.type.numeric.RealType;
+import net.imglib2.type.numeric.real.DoubleType;
+import net.imglib2.util.Intervals;
+
+public interface DownsampleBlock< T extends RealType< T > >
+{
+	void downsampleBlock( final RandomAccess< T > in, final Cursor< T > out, final int[] dimensions );
+
+	static < T extends RealType< T > > DownsampleBlock< T > create(
+			final int[] blockDimensions,
+			final int[] downsamplingFactors,
+			final Class< ?  > pixelTypeClass,
+			final Class< ? > inAccessClass )
+	{
+		return DownsampleBlockInstances.create( blockDimensions, downsamplingFactors, pixelTypeClass, inAccessClass );
+	}
+}
+
+class DownsampleBlockInstances
+{
+	@SuppressWarnings( "rawtypes" )
+	private static ClassCopyProvider< DownsampleBlock > provider;
+
+	@SuppressWarnings( "unchecked" )
+	public static < T extends RealType< T > > DownsampleBlock< T > create(
+			final int[] blockDimensions,
+			final int[] downsamplingFactors,
+			final Class< ?  > pixelTypeClass,
+			final Class< ? > inAccessClass )
+	{
+		if ( provider == null )
+		{
+			synchronized ( DownsampleBlockInstances.class )
+			{
+				if ( provider == null )
+					provider = new ClassCopyProvider<>( Imp.class, DownsampleBlock.class, int[].class, int[].class );
+			}
+		}
+
+		final int numDimensions = blockDimensions.length;
+
+		Object key = Arrays.asList( numDimensions, pixelTypeClass, inAccessClass );
+		return provider.newInstanceForKey( key, blockDimensions, downsamplingFactors );
+	}
+
+	public static class Imp< T extends RealType< T > > implements DownsampleBlock< T >
+	{
+		private final int n;
+
+		private final int[] downsamplingFactors;
+
+		private final double scale;
+
+		private final double[] accumulator;
+
+		private final RandomAccess< DoubleType > acc;
+
+		public Imp(
+				final int[] blockDimensions,
+				final int[] downsamplingFactors )
+		{
+			n = blockDimensions.length;
+			if ( n < 1 || n > 3 )
+				throw new IllegalArgumentException();
+
+			this.downsamplingFactors = downsamplingFactors;
+			scale = 1.0 / Intervals.numElements( downsamplingFactors );
+
+			accumulator = new double[ ( int ) Intervals.numElements( blockDimensions ) ];
+
+			final long[] dims = new long[ n ];
+			Arrays.setAll( dims, d -> blockDimensions[ d ] );
+			acc = ArrayImgs.doubles( accumulator, dims ).randomAccess();
+		}
+
+		@Override
+		public void downsampleBlock(
+				final RandomAccess< T > in,
+				final Cursor< T > out, // must be flat iteration order
+				final int[] dimensions )
+		{
+			clearAccumulator();
+
+			if ( n == 3 )
+			{
+				downsampleBlock3D( acc, dimensions[ 0 ], dimensions[ 1 ], dimensions[ 2 ], in );
+				writeOutput3D( out, dimensions[ 0 ], dimensions[ 1 ], dimensions[ 2 ], acc );
+			}
+			else if ( n == 2 )
+			{
+				downsampleBlock2D( acc, dimensions[ 0 ], dimensions[ 1 ], in );
+				writeOutput2D( out, dimensions[ 0 ], dimensions[ 1 ], acc );
+			}
+			else
+			{
+				downsampleBlock1D( acc, dimensions[ 0 ], in );
+				writeOutput1D( out, dimensions[ 0 ], acc );
+			}
+		}
+
+		private void clearAccumulator()
+		{
+			Arrays.fill( accumulator, 0, accumulator.length, 0 );
+		}
+
+		private void downsampleBlock3D(
+				final RandomAccess< DoubleType > acc,
+				final int asx, // size of output (resp accumulator) image
+				final int asy,
+				final int asz,
+				final RandomAccess< T > in )
+		{
+			final int bsz = downsamplingFactors[ 2 ];
+			final int sz = asz * bsz;
+			for ( int z = 0, bz = 0; z < sz; ++z )
+			{
+				downsampleBlock2D( acc, asx, asy, in );
+				in.fwd( 2 );
+				if ( ++bz == bsz )
+				{
+					bz = 0;
+					acc.fwd( 2 );
+				}
+			}
+			in.move( -sz, 2 );
+			acc.move( -asz, 2 );
+		}
+
+		private void downsampleBlock2D(
+				final RandomAccess< DoubleType > acc,
+				final int asx, // size of output (resp accumulator) image
+				final int asy,
+				final RandomAccess< T > in )
+		{
+			final int bsy = downsamplingFactors[ 1 ];
+			final int sy = asy * bsy;
+			for ( int y = 0, by = 0; y < sy; ++y )
+			{
+				downsampleBlock1D( acc, asx, in );
+				in.fwd( 1 );
+				if ( ++by == bsy )
+				{
+					by = 0;
+					acc.fwd( 1 );
+				}
+			}
+			in.move( -sy, 1 );
+			acc.move( -asy, 1 );
+		}
+
+		private void downsampleBlock1D(
+				final RandomAccess< DoubleType > acc,
+				final int asx, // size of output (resp accumulator) image
+				final RandomAccess< T > in )
+		{
+			final int bsx = downsamplingFactors[ 0 ];
+			final int sx = asx * bsx;
+			for ( int x = 0, bx = 0; x < sx; ++x )
+			{
+				acc.get().set( acc.get().get() + in.get().getRealDouble() );
+				in.fwd( 0 );
+				if ( ++bx == bsx )
+				{
+					bx = 0;
+					acc.fwd( 0 );
+				}
+			}
+			in.move( -sx, 0 );
+			acc.move( -asx, 0 );
+		}
+
+		private void writeOutput3D(
+				final Cursor< T > out, // must be flat iteration order
+				final int asx, // size of output (resp accumulator) image
+				final int asy,
+				final int asz,
+				final RandomAccess< DoubleType > acc )
+		{
+			for ( int z = 0; z < asz; ++z )
+			{
+				writeOutput2D( out, asx, asy, acc );
+				acc.fwd( 2 );
+			}
+			acc.move( -asz, 2 );
+		}
+
+		private void writeOutput2D(
+				final Cursor< T > out, // must be flat iteration order
+				final int asx, // size of output (resp accumulator) image
+				final int asy,
+				final RandomAccess< DoubleType > acc )
+		{
+			for ( int y = 0; y < asy; ++y )
+			{
+				writeOutput1D( out, asx, acc );
+				acc.fwd( 1 );
+			}
+			acc.move( -asy, 1 );
+		}
+
+		private void writeOutput1D(
+				final Cursor< T > out, // must be flat iteration order
+				final int asx, // size of output (resp accumulator) image
+				final RandomAccess< DoubleType > acc )
+		{
+			final double scale = this.scale;
+			for ( int x = 0; x < asx; ++x )
+			{
+				out.next().setReal( acc.get().get() * scale );
+				acc.fwd( 0 );
+			}
+			acc.move( -asx, 0 );
+		}
+	}
+}
diff --git a/src/main/java/bdv/export/ExportMipmapInfo.java b/src/main/java/bdv/export/ExportMipmapInfo.java
index 5a258314272bf30a51892d8233358775b3b0125b..c146fe4aa8da16ce3d1cb7509417fc9e91c940c2 100644
--- a/src/main/java/bdv/export/ExportMipmapInfo.java
+++ b/src/main/java/bdv/export/ExportMipmapInfo.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/export/ExportScalePyramid.java b/src/main/java/bdv/export/ExportScalePyramid.java
new file mode 100644
index 0000000000000000000000000000000000000000..52c85d67682f9d755b1851ade8e1a4667116911e
--- /dev/null
+++ b/src/main/java/bdv/export/ExportScalePyramid.java
@@ -0,0 +1,441 @@
+/*
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.export;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import net.imglib2.FinalInterval;
+import net.imglib2.RandomAccess;
+import net.imglib2.RandomAccessibleInterval;
+import net.imglib2.cache.img.SingleCellArrayImg;
+import net.imglib2.img.basictypeaccess.ArrayDataAccessFactory;
+import net.imglib2.img.basictypeaccess.array.ArrayDataAccess;
+import net.imglib2.img.cell.CellGrid;
+import net.imglib2.type.NativeType;
+import net.imglib2.type.NativeTypeFactory;
+import net.imglib2.type.numeric.RealType;
+import net.imglib2.util.Cast;
+import net.imglib2.util.Intervals;
+import net.imglib2.view.Views;
+
+/**
+ * Write an image to a chunked mipmap representation.
+ */
+public class ExportScalePyramid
+{
+	/**
+	 * A heuristic to decide for a given resolution level whether the source
+	 * pixels should be taken from the original image or read from a previously
+	 * written resolution level in the output dataset.
+	 */
+	public interface LoopbackHeuristic
+	{
+		/**
+		 * @return {@code true} if source pixels should be read back from
+		 *         dataset. {@code false} if source pixels should be taken from
+		 *         original image.
+		 */
+		boolean decide(
+				final RandomAccessibleInterval< ? > originalImg,
+				final int[] factorsToOriginalImg,
+				final int previousLevel,
+				final int[] factorsToPreviousLevel,
+				final int[] chunkSize );
+	}
+
+	/**
+	 * Simple heuristic: use loopback image loader if saving 8 times or more on
+	 * number of pixel access with respect to the original image.
+	 */
+	public static class DefaultLoopbackHeuristic implements LoopbackHeuristic
+	{
+		@Override
+		public boolean decide( final RandomAccessibleInterval< ? > originalImg, final int[] factorsToOriginalImg, final int previousLevel, final int[] factorsToPreviousLevel, final int[] chunkSize )
+		{
+			if ( previousLevel < 0 )
+				return false;
+
+			if ( Intervals.numElements( factorsToOriginalImg ) / Intervals.numElements( factorsToPreviousLevel ) >= 8 )
+				return true;
+
+			return false;
+		}
+	}
+
+	/**
+	 * Callback that is called after each "plane of blocks" is written, giving
+	 * the opportunity to clear caches, etc.
+	 */
+	public interface AfterEachPlane
+	{
+		/**
+		 * Called after a "plane of blocks" is written.
+		 *
+		 * @param usedLoopBack
+		 *            {@code true}, if source was previously written resolution
+		 *            level in the output dataset. {@code false}, if source was
+		 *            the original image.
+		 */
+		void afterEachPlane( final boolean usedLoopBack );
+	}
+
+	/**
+	 * A block to be written. See {@link DatasetIO#writeBlock(Object, Block)
+	 * DatasetIO.writeBlock()}.
+	 */
+	public static class Block< T extends NativeType< T > >
+	{
+		final SingleCellArrayImg< T, ? > data;
+		final int[] size;
+		final long[] position;
+
+		Block( final SingleCellArrayImg< T, ? > data, final int[] size, final long[] position )
+		{
+			this.data = data;
+			this.size = size;
+			this.position = position;
+		}
+
+		public SingleCellArrayImg< T, ? > getData()
+		{
+			return data;
+		}
+
+		public int[] getSize()
+		{
+			return size;
+		}
+
+		public long[] getGridPosition()
+		{
+			return position;
+		}
+	}
+
+	/**
+	 * Writing and reading back data for each resolution level.
+	 *
+	 * @param <D>
+	 *            Dataset handle
+	 * @param <T>
+	 *            Pixel type
+	 */
+	public interface DatasetIO< D, T extends NativeType< T > >
+	{
+		/**
+		 * Create a dataset for the image of the given resolution {@code level}.
+		 *
+		 * @return a handle to the dataset.
+		 */
+		D createDataset(
+				final int level,
+				final long[] dimensions,
+				final int[] blockSize ) throws IOException;
+
+		/**
+		 * Write the given {@code dataBlock} to the {@code dataset}.
+		 */
+		void writeBlock(
+				final D dataset,
+				final Block< T > dataBlock ) throws IOException;
+
+		/**
+		 * Blocks until all pending data was written to {@code dataset}.
+		 */
+		void flush( D dataset ) throws IOException;
+
+		/**
+		 * Opens a dataset that was already written as a
+		 * {@code RaπdomAccessibleInterval}.
+		 */
+		default RandomAccessibleInterval< T > getImage( final int level ) throws IOException
+		{
+			return null;
+		}
+	}
+
+	/**
+	 * Write an image to a chunked mipmap representation.
+	 *
+	 * @param img
+	 *            the image to be written.
+	 * @param type
+	 *            instance of the pixel type of the image.
+	 * @param mipmapInfo
+	 *            contains for each mipmap level of the setup, the subsampling
+	 *            factors and block sizes.
+	 * @param io
+	 *            writer for image blocks.
+	 * @param executorService
+	 *            ExecutorService where block-creator tasks are submitted.
+	 * @param numThreads
+	 *            How many block-creator tasks to run in parallel. (This many
+	 *            tasks are submitted to the @code
+	 * @param loopbackHeuristic
+	 *            heuristic to decide whether to create each resolution level by
+	 *            reading pixels from the original image or by reading back a
+	 *            finer resolution level already written to the hdf5. may be
+	 *            null (in this case always use the original image).
+	 * @param afterEachPlane
+	 *            this is called after each "plane of blocks" is written, giving
+	 *            the opportunity to clear caches, etc. may be null.
+	 * @param progressWriter
+	 *            completion ratio and status output will be directed here. may
+	 *            be null.
+	 *
+	 * @param <T>
+	 *            Pixel type
+	 * @param <D>
+	 *            Dataset handle
+	 *
+	 * @throws IOException
+	 */
+	public static < T extends RealType< T > & NativeType< T >, D > void writeScalePyramid(
+			final RandomAccessibleInterval< T > img,
+			final T type,
+			final ExportMipmapInfo mipmapInfo,
+			final DatasetIO< D, T > io,
+			final ExecutorService executorService,
+			final int numThreads,
+			final LoopbackHeuristic loopbackHeuristic,
+			final AfterEachPlane afterEachPlane,
+			ProgressWriter progressWriter ) throws IOException
+	{
+		final BlockCreator< T > blockCreator = BlockCreator.forType( type );
+
+		if ( progressWriter == null )
+			progressWriter = new ProgressWriterNull();
+
+		// for progressWriter
+		final int numTasks = mipmapInfo.getNumLevels();
+		int numCompletedTasks = 0;
+		progressWriter.setProgress( 0.0 );
+
+		// write image data for all views to the HDF5 file
+		final int n = 3; // TODO checkNumDimensions( img.numDimensions() );
+		final long[] dimensions = new long[ n ];
+
+		final int[][] resolutions = mipmapInfo.getExportResolutions();
+		final int[][] subdivisions = mipmapInfo.getSubdivisions();
+		final int numLevels = mipmapInfo.getNumLevels();
+
+		for ( int level = 0; level < numLevels; ++level )
+		{
+			progressWriter.out().println( "writing level " + level );
+
+			boolean useLoopBack = false;
+			int[] factorsToPreviousLevel = null;
+			RandomAccessibleInterval< T > loopbackImg = null;
+			if ( loopbackHeuristic != null )
+			{
+				// Are downsampling factors a multiple of a level that we have
+				// already written?
+				int previousLevel = -1;
+				A:
+				for ( int l = level - 1; l >= 0; --l )
+				{
+					final int[] f = new int[ n ];
+					for ( int d = 0; d < n; ++d )
+					{
+						f[ d ] = resolutions[ level ][ d ] / resolutions[ l ][ d ];
+						if ( f[ d ] * resolutions[ l ][ d ] != resolutions[ level ][ d ] )
+							continue A;
+					}
+					factorsToPreviousLevel = f;
+					previousLevel = l;
+					break;
+				}
+				// Now, if previousLevel >= 0 we can use loopback ImgLoader on
+				// previousLevel and downsample with factorsToPreviousLevel.
+				//
+				// whether it makes sense to actually do so is determined by a
+				// heuristic based on the following considerations:
+				// * if downsampling a lot over original image, the cost of
+				//   reading images back from hdf5 outweighs the cost of
+				//   accessing and averaging original pixels.
+				// * original image may already be cached (for example when
+				//   exporting an ImageJ virtual stack. To compute blocks
+				//   that downsample a lot in Z, many planes of the virtual
+				//   stack need to be accessed leading to cache thrashing if
+				//   individual planes are very large.
+
+				if ( previousLevel >= 0 )
+					useLoopBack = loopbackHeuristic.decide( img, resolutions[ level ], previousLevel, factorsToPreviousLevel, subdivisions[ level ] );
+
+				if ( useLoopBack )
+					loopbackImg = io.getImage( previousLevel );
+
+				if ( loopbackImg == null )
+					useLoopBack = false;
+			}
+
+			final RandomAccessibleInterval< T > sourceImg;
+			final int[] factor;
+			if ( useLoopBack )
+			{
+				sourceImg = loopbackImg;
+				factor = factorsToPreviousLevel;
+			}
+			else
+			{
+				sourceImg = img;
+				factor = resolutions[ level ];
+			}
+
+			sourceImg.dimensions( dimensions );
+
+			final long size = Intervals.numElements( factor );
+			final boolean fullResolution = size == 1;
+			if ( !fullResolution )
+			{
+				for ( int d = 0; d < n; ++d )
+					dimensions[ d ] = Math.max( dimensions[ d ] / factor[ d ], 1 );
+			}
+
+			final long[] minRequiredInput = new long[ n ];
+			final long[] maxRequiredInput = new long[ n ];
+			sourceImg.min( minRequiredInput );
+			for ( int d = 0; d < n; ++d )
+				maxRequiredInput[ d ] = minRequiredInput[ d ] + dimensions[ d ] * factor[ d ] - 1;
+
+			// TODO: pass OutOfBoundsFactory
+			final RandomAccessibleInterval< T > extendedImg = Views.interval( Views.extendBorder( sourceImg ), new FinalInterval( minRequiredInput, maxRequiredInput ) );
+
+			final int[] cellDimensions = subdivisions[ level ];
+			final D dataset = io.createDataset( level, dimensions, cellDimensions );
+
+			final ProgressWriter subProgressWriter = new SubTaskProgressWriter(
+					progressWriter, ( double ) numCompletedTasks / numTasks,
+					( double ) ( numCompletedTasks + 1 ) / numTasks );
+			// generate one "plane" of cells after the other to avoid cache thrashing when exporting from virtual stacks
+			final CellGrid grid = new CellGrid( dimensions, cellDimensions );
+			final long[] numCells = grid.getGridDimensions();
+			final long numBlocksPerPlane = numElements( numCells, 0, 2 );
+			final long numPlanes = numElements( numCells, 2, n );
+			for ( int plane = 0; plane < numPlanes; ++plane )
+			{
+				final long planeBaseIndex = numBlocksPerPlane * plane;
+				final AtomicInteger nextCellInPlane = new AtomicInteger();
+				final List< Callable< Void > > tasks = new ArrayList<>();
+				for ( int threadNum = 0; threadNum < numThreads; ++threadNum )
+				{
+					tasks.add( () -> {
+						final long[] currentCellMin = new long[ n ];
+						final int[] currentCellDim = new int[ n ];
+						final long[] currentCellPos = new long[ n ];
+						final long[] blockMin = new long[ n ];
+						final RandomAccess< T > in = extendedImg.randomAccess();
+
+						final Class< ? extends RealType > kl1 = type.getClass();
+						final Class< ? extends RandomAccess > kl2 = in.getClass();
+						final CopyBlock< T > copyBlock = fullResolution ? CopyBlock.create( n, kl1, kl2 ) : null;
+						final DownsampleBlock< T > downsampleBlock = fullResolution ? null : DownsampleBlock.create( cellDimensions, factor, kl1, kl2 );
+
+						for ( int i = nextCellInPlane.getAndIncrement(); i < numBlocksPerPlane; i = nextCellInPlane.getAndIncrement() )
+						{
+							final long index = planeBaseIndex + i;
+
+							grid.getCellDimensions( index, currentCellMin, currentCellDim );
+							grid.getCellGridPositionFlat( index, currentCellPos );
+							final Block< T > block = blockCreator.create( currentCellDim, currentCellMin, currentCellPos );
+
+							if ( fullResolution )
+							{
+								final RandomAccess< T > out = block.getData().randomAccess();
+								in.setPosition( currentCellMin );
+								out.setPosition( currentCellMin );
+								copyBlock.copyBlock( in, out, currentCellDim );
+							}
+							else
+							{
+								for ( int d = 0; d < n; ++d )
+									blockMin[ d ] = currentCellMin[ d ] * factor[ d ];
+								in.setPosition( blockMin );
+								downsampleBlock.downsampleBlock( in, block.getData().cursor(), currentCellDim );
+							}
+
+							io.writeBlock( dataset, block );
+						}
+						return null;
+					} );
+				}
+				try
+				{
+					final List< Future< Void > > futures = executorService.invokeAll( tasks );
+					for ( final Future< Void > future : futures )
+						future.get();
+				}
+				catch ( final InterruptedException | ExecutionException e )
+				{
+					// TODO...
+					e.printStackTrace();
+					throw new IOException( e );
+				}
+				if ( afterEachPlane != null )
+					afterEachPlane.afterEachPlane( useLoopBack );
+
+				subProgressWriter.setProgress( ( double ) plane / numPlanes );
+			}
+			io.flush( dataset );
+			progressWriter.setProgress( ( double ) ++numCompletedTasks / numTasks );
+		}
+	}
+
+	private static long numElements( final long[] size, final int mind, final int maxd )
+	{
+		long numElements = 1;
+		for ( int d = mind; d < maxd; ++d )
+			numElements *= size[ d ];
+		return numElements;
+	}
+
+	private interface BlockCreator< T extends NativeType< T > >
+	{
+		Block< T > create( final int[] blockSize, final long[] blockMin, final long[] gridPosition );
+
+		static < T extends NativeType< T > & RealType< T >, A extends ArrayDataAccess< A > > BlockCreator< T > forType( final T type )
+		{
+			final A accessFactory = Cast.unchecked( ArrayDataAccessFactory.get( type ) );
+			final NativeTypeFactory< T, A > nativeTypeFactory = Cast.unchecked( type.getNativeTypeFactory() );
+			return ( blockSize, blockMin, gridPosition ) -> {
+				final A data = accessFactory.createArray( ( int ) Intervals.numElements( blockSize ) );
+				final SingleCellArrayImg< T, A > img = new SingleCellArrayImg<>( blockSize, blockMin, data, null );
+				img.setLinkedType( nativeTypeFactory.createLinkedType( img ) );
+				return new Block<>( img, blockSize, gridPosition );
+			};
+		}
+	}
+}
diff --git a/src/main/java/bdv/export/HDF5Access.java b/src/main/java/bdv/export/HDF5Access.java
index 5c31cf13fb017b9e8aa0bceb7321412ed697ba6d..5cff238cfff3f75f56e6803365135aa2d49c0c1f 100644
--- a/src/main/java/bdv/export/HDF5Access.java
+++ b/src/main/java/bdv/export/HDF5Access.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/export/HDF5AccessHack.java b/src/main/java/bdv/export/HDF5AccessHack.java
index 4e187bf8ea6156184b30c82ae455e97860c8bc0e..6cdc0ee7c0ca3a6b2ddc84a8c2576cfa5c50501a 100644
--- a/src/main/java/bdv/export/HDF5AccessHack.java
+++ b/src/main/java/bdv/export/HDF5AccessHack.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/export/Hdf5BlockWriterThread.java b/src/main/java/bdv/export/Hdf5BlockWriterThread.java
index b0cedc6eca21ae0dc64e0d11c0986c09e7b881ff..a799c401178b784857f1c861244dd7e26f88188e 100644
--- a/src/main/java/bdv/export/Hdf5BlockWriterThread.java
+++ b/src/main/java/bdv/export/Hdf5BlockWriterThread.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -42,9 +41,9 @@ class Hdf5BlockWriterThread extends Thread implements IHDF5Access
 {
 	private final IHDF5Access hdf5Access;
 
-	private static interface Hdf5Task
+	private interface Hdf5Task
 	{
-		public void run( final IHDF5Access hdf5Access );
+		void run( final IHDF5Access hdf5Access );
 	}
 
 	private final BlockingQueue< Hdf5BlockWriterThread.Hdf5Task > queue;
@@ -163,6 +162,7 @@ class Hdf5BlockWriterThread extends Thread implements IHDF5Access
 	public void closeDataset()
 	{
 		put( new CloseDatasetTask() );
+		waitUntilEmpty();
 	}
 
 	private boolean put( final Hdf5BlockWriterThread.Hdf5Task task )
diff --git a/src/main/java/bdv/export/IHDF5Access.java b/src/main/java/bdv/export/IHDF5Access.java
index 4c8b65bcf41ba4a429847b8b0381a141ad38b8eb..ac6dd19c401b8a2751430c07e7aaf02fc4418734 100644
--- a/src/main/java/bdv/export/IHDF5Access.java
+++ b/src/main/java/bdv/export/IHDF5Access.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -34,16 +33,16 @@ import ch.systemsx.cisd.hdf5.IHDF5Writer;
 
 interface IHDF5Access
 {
-	public void writeMipmapDescription( final int setupIdPartition, final ExportMipmapInfo mipmapInfo );
+	void writeMipmapDescription( final int setupIdPartition, final ExportMipmapInfo mipmapInfo );
 
-	public void createAndOpenDataset( final String path, long[] dimensions, int[] cellDimensions, HDF5IntStorageFeatures features );
+	void createAndOpenDataset( final String path, long[] dimensions, int[] cellDimensions, HDF5IntStorageFeatures features );
 
-	public void writeBlockWithOffset( final short[] data, final long[] blockDimensions, final long[] offset );
+	void writeBlockWithOffset( final short[] data, final long[] blockDimensions, final long[] offset );
 
-	public void closeDataset();
+	void closeDataset();
 
-	public void close();
+	void close();
 
 	// this is for sharing with Hdf5ImageLoader for loopback loader when exporting
-	public IHDF5Writer getIHDF5Writer();
+	IHDF5Writer getIHDF5Writer();
 }
diff --git a/src/main/java/bdv/export/ProgressWriter.java b/src/main/java/bdv/export/ProgressWriter.java
index 9172db18f2aef6adf9e14c0ee4010d9841da636a..f8ec44c2aa261d5f5da96013e8f72b0fdd992de5 100644
--- a/src/main/java/bdv/export/ProgressWriter.java
+++ b/src/main/java/bdv/export/ProgressWriter.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -33,9 +32,9 @@ import java.io.PrintStream;
 
 public interface ProgressWriter
 {
-	public PrintStream out();
+	PrintStream out();
 
-	public PrintStream err();
+	PrintStream err();
 
-	public void setProgress( double completionRatio );
+	void setProgress( double completionRatio );
 }
diff --git a/src/main/java/bdv/export/ProgressWriterConsole.java b/src/main/java/bdv/export/ProgressWriterConsole.java
index d5cd56a990ac5ce6666caf16c14e11f5ce320876..a480b08a91b0ac18d3beaff88a293c7a014a5775 100644
--- a/src/main/java/bdv/export/ProgressWriterConsole.java
+++ b/src/main/java/bdv/export/ProgressWriterConsole.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/export/ProgressWriterNull.java b/src/main/java/bdv/export/ProgressWriterNull.java
new file mode 100644
index 0000000000000000000000000000000000000000..3337ff2eb1e734c96cf00c8c87a23dddcb420bf9
--- /dev/null
+++ b/src/main/java/bdv/export/ProgressWriterNull.java
@@ -0,0 +1,70 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.export;
+
+import java.io.OutputStream;
+import java.io.PrintStream;
+
+public class ProgressWriterNull implements ProgressWriter
+{
+	private final PrintStream blackhole;
+
+	public ProgressWriterNull()
+	{
+		blackhole = new PrintStream( new OutputStream() {
+			@Override
+			public void write( final int b )
+			{}
+
+			@Override
+			public void write( final byte[] b )
+			{}
+
+			@Override
+			public void write( final byte[] b, final int off, final int len )
+			{}
+		} );
+	}
+
+	@Override
+	public PrintStream out()
+	{
+		return blackhole;
+	}
+
+	@Override
+	public PrintStream err()
+	{
+		return blackhole;
+	}
+
+	@Override
+	public void setProgress( final double completionRatio )
+	{}
+}
diff --git a/src/main/java/bdv/export/ProposeMipmaps.java b/src/main/java/bdv/export/ProposeMipmaps.java
index e71afb9214aae57521830c3a959f43d66d0610d3..0fe75311240bd5d8fafe557c21670ad8672b3f52 100644
--- a/src/main/java/bdv/export/ProposeMipmaps.java
+++ b/src/main/java/bdv/export/ProposeMipmaps.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -30,6 +29,7 @@
 package bdv.export;
 
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.HashMap;
 import java.util.Map;
 
@@ -43,11 +43,13 @@ import mpicbg.spim.data.sequence.VoxelDimensions;
  *
  * <p>
  * Choice of proposed chunksize is not based on any hard benchmark data
- * currently. Chunksize is set as either 16x16x16 or 32x32x4 depending on which
- * one is closer to isotropic. It is very likely that more efficient choices can
- * be found by manual tuning, depending on hardware and use case.
+ * currently. Chunk sizes are proposed such that chunks have power-of-two side
+ * lengths, are roughly square in world space, and contain close to (but not
+ * more than) a specified number of elements (4096 by default). It is very
+ * likely that more efficient choices can be found by manual tuning, depending
+ * on hardware and use case.
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
 public class ProposeMipmaps
 {
@@ -69,12 +71,28 @@ public class ProposeMipmaps
 	/**
 	 * Propose number of mipmap levels as well subsampling factors and chunk
 	 * size for each level, based on the image and voxel size of the given
-	 * setup.
+	 * setup. Chunks contain close to (but not more than) 4096 elements.
 	 *
 	 * @param setup
 	 * @return proposed mipmap settings
 	 */
 	public static ExportMipmapInfo proposeMipmaps( final BasicViewSetup setup )
+	{
+		return proposeMipmaps( setup, 4096 );
+	}
+
+	/**
+	 * Propose number of mipmap levels as well subsampling factors and chunk
+	 * size for each level, based on the image and voxel size of the given
+	 * setup. Chunk sizes are proposed such that chunks have power-of-two side
+	 * lengths, are roughly square in world space, and contain close to (but not
+	 * more than) {@code maxNumElements}.
+	 *
+	 * @param setup
+	 * @param maxNumElements
+	 * @return proposed mipmap settings
+	 */
+	public static ExportMipmapInfo proposeMipmaps( final BasicViewSetup setup, final int maxNumElements )
 	{
 		final VoxelDimensions voxelSize = setup.getVoxelSize();
 		final double[] voxelScale = new double[ 3 ];
@@ -88,7 +106,7 @@ public class ProposeMipmaps
 		final ArrayList< int[] > subdivisions = new ArrayList<>();
 
 //		for ( int level = 0;; ++level )
-		while( true )
+		while ( true )
 		{
 			resolutions.add( res.clone() );
 
@@ -102,10 +120,7 @@ public class ProposeMipmaps
 					dmax = d;
 				}
 			}
-			if ( ( 4 * vmax / 32 ) > ( 1 / vmax ) )
-				subdivisions.add( subdiv_32_32_4[ dmax ] );
-			else
-				subdivisions.add( subdiv_16_16_16 );
+			subdivisions.add( suggestPoTBlockSize( voxelScale, maxNumElements ) );
 
 			setup.getSize().dimensions( size );
 			long maxSize = 0;
@@ -175,7 +190,75 @@ public class ProposeMipmaps
 			size[ d ] /= minVoxelDim;
 	}
 
-	private static int[] subdiv_16_16_16 = new int[] { 16, 16, 16 };
+	/**
+	 * Propose block size such that
+	 * <ol>
+	 * <li>each dimension is power-of-two,</li>
+	 * <li>number of elements is as big as possible, but not larger than
+	 * {@code maxNumElements}</li>
+	 * <li>and the block (scaled by the {@code voxelSize}) is as close to square
+	 * as possible given constraints 1 and 2.</li>
+	 * </ol>
+	 */
+	public static int[] suggestPoTBlockSize( final double[] voxelSize, final int maxNumElements )
+	{
+		final int n = voxelSize.length;
+		final double[] bias = new double[ n ];
+		Arrays.setAll( bias, d -> 0.01 * ( n - d ) );
+		return suggestPoTBlockSize( voxelSize, maxNumElements, bias );
+	}
 
-	private static int[][] subdiv_32_32_4 = new int[][] { { 4, 32, 32 }, { 32, 4, 32 }, { 32, 32, 4 } };
+	/**
+	 * Propose block size such that
+	 * <ol>
+	 * <li>each dimension is power-of-two,</li>
+	 * <li>number of elements is as big as possible, but not larger than
+	 * {@code maxNumElements}</li>
+	 * <li>and the block (scaled by the {@code voxelSize}) is as close to square
+	 * as possible given constraints 1 and 2.</li>
+	 * </ol>
+	 *
+	 * Determination works by finding real PoT for each dimension, then rounding
+	 * down, and increasing one by one the PoT for dimensions until going over
+	 * maxNumElements. Dimensions are ordered by decreasing fractional remainder
+	 * of real PoT plus some per-dimension bias (usually set such that X is
+	 * enlarged before Y before Z...)
+	 */
+	private static int[] suggestPoTBlockSize( final double[] voxelSize, final int maxNumElements, final double[] bias )
+	{
+		final int n = voxelSize.length;
+		final double[] shape = new double[ n ];
+		double shapeVol = 1;
+		for ( int d = 0; d < n; ++d )
+		{
+			shape[ d ] = 1 / voxelSize[ d ];
+			shapeVol *= shape[ d ];
+		}
+		final double m = Math.pow( maxNumElements / shapeVol, 1. /  n  );
+		final double sumNumBits = Math.log( maxNumElements ) / Math.log( 2 );
+		final double[] numBits = new double[ n ];
+		Arrays.setAll( numBits, d -> Math.log( m * shape[ d ] ) / Math.log( 2 ) );
+		final int[] intNumBits = new int[ n ];
+		Arrays.setAll( intNumBits, d -> Math.max( 0, ( int ) numBits[ d ] ) );
+		for ( int sumIntNumBits = Arrays.stream( intNumBits ).sum(); sumIntNumBits + 1 <= sumNumBits; ++sumIntNumBits )
+		{
+			double maxDiff = 0;
+			int maxDiffDim = 0;
+			for ( int d = 0; d < n; ++d )
+			{
+				final double diff = numBits[ d ] - intNumBits[ d ] + bias[ d ];
+				if ( diff > maxDiff )
+				{
+					maxDiff = diff;
+					maxDiffDim = d;
+				}
+			}
+			++intNumBits[ maxDiffDim ];
+		}
+
+		final int[] blockSize = new int[ n ];
+		for ( int d = 0; d < n; ++d )
+			blockSize[ d ] = 1 << intNumBits[ d ];
+		return blockSize;
+	}
 }
diff --git a/src/main/java/bdv/export/SubTaskProgressWriter.java b/src/main/java/bdv/export/SubTaskProgressWriter.java
index b3b0d3681c6d0f00b8cb8d2848b46548236c7dd9..7dc850d9263a85430ffe720026bb09642ead1b1b 100644
--- a/src/main/java/bdv/export/SubTaskProgressWriter.java
+++ b/src/main/java/bdv/export/SubTaskProgressWriter.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/export/WriteSequenceToHdf5.java b/src/main/java/bdv/export/WriteSequenceToHdf5.java
index a47599c1d18589b7aae471ec1b84072deeb8e721..a2294dfe97274bcf97ead885c67221c5fccdfbc4 100644
--- a/src/main/java/bdv/export/WriteSequenceToHdf5.java
+++ b/src/main/java/bdv/export/WriteSequenceToHdf5.java
@@ -1,19 +1,18 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
- *
+ * 
  * 1. Redistributions of source code must retain the above copyright notice,
  *    this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
  *    this list of conditions and the following disclaimer in the documentation
  *    and/or other materials provided with the distribution.
- *
+ * 
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
@@ -29,17 +28,10 @@
  */
 package bdv.export;
 
-import java.io.File;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Map.Entry;
-import java.util.concurrent.CountDownLatch;
-
-import bdv.export.WriteSequenceToHdf5.AfterEachPlane;
-import bdv.export.WriteSequenceToHdf5.LoopbackHeuristic;
+import bdv.export.ExportScalePyramid.AfterEachPlane;
+import bdv.export.ExportScalePyramid.Block;
+import bdv.export.ExportScalePyramid.DatasetIO;
+import bdv.export.ExportScalePyramid.LoopbackHeuristic;
 import bdv.img.hdf5.Hdf5ImageLoader;
 import bdv.img.hdf5.Partition;
 import bdv.img.hdf5.Util;
@@ -48,6 +40,15 @@ import ch.systemsx.cisd.hdf5.HDF5Factory;
 import ch.systemsx.cisd.hdf5.HDF5IntStorageFeatures;
 import ch.systemsx.cisd.hdf5.IHDF5Reader;
 import ch.systemsx.cisd.hdf5.IHDF5Writer;
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
 import mpicbg.spim.data.XmlHelpers;
 import mpicbg.spim.data.generic.sequence.AbstractSequenceDescription;
 import mpicbg.spim.data.generic.sequence.BasicImgLoader;
@@ -56,19 +57,13 @@ import mpicbg.spim.data.generic.sequence.BasicViewSetup;
 import mpicbg.spim.data.sequence.TimePoint;
 import mpicbg.spim.data.sequence.TimePoints;
 import mpicbg.spim.data.sequence.ViewId;
-import net.imglib2.Cursor;
 import net.imglib2.Dimensions;
-import net.imglib2.FinalInterval;
-import net.imglib2.RandomAccess;
 import net.imglib2.RandomAccessibleInterval;
-import net.imglib2.img.array.ArrayImg;
-import net.imglib2.img.array.ArrayImgs;
-import net.imglib2.img.basictypeaccess.array.ShortArray;
+import net.imglib2.cache.img.SingleCellArrayImg;
 import net.imglib2.img.cell.CellImg;
-import net.imglib2.iterator.LocalizingIntervalIterator;
-import net.imglib2.type.numeric.RealType;
 import net.imglib2.type.numeric.integer.UnsignedShortType;
-import net.imglib2.view.Views;
+import net.imglib2.util.Cast;
+import net.imglib2.util.Intervals;
 
 /**
  * Create a hdf5 files containing image data from all views and all timepoints
@@ -96,7 +91,7 @@ import net.imglib2.view.Views;
  * A data-set can be stored in a single hdf5 file or split across several hdf5
  * "partitions" with one master hdf5 linking into the partitions.
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
 public class WriteSequenceToHdf5
 {
@@ -400,8 +395,7 @@ public class WriteSequenceToHdf5
 			writerQueue.start();
 
 			// start CellCreatorThreads
-			final CellCreatorThread[] cellCreatorThreads = createAndStartCellCreatorThreads( numCellCreatorThreads );
-
+			final ExecutorService executorService = Executors.newFixedThreadPool( numCellCreatorThreads );
 			try
 			{
 				// calculate number of tasks for progressWriter
@@ -456,13 +450,13 @@ public class WriteSequenceToHdf5
 
 						writeViewToHdf5PartitionFile(
 								img, timepointIdPartition, setupIdPartition, mipmapInfo, false,
-								deflate, writerQueue, cellCreatorThreads, loopbackHeuristic, afterEachPlane, subProgressWriter );
+								deflate, writerQueue, executorService, numCellCreatorThreads, loopbackHeuristic, afterEachPlane, subProgressWriter );
 					}
 				}
 			}
 			finally
 			{
-				stopCellCreatorThreads( cellCreatorThreads );
+				executorService.shutdown();
 			}
 		}
 		finally {
@@ -534,15 +528,15 @@ public class WriteSequenceToHdf5
 		try
 		{
 			writerQueue.start();
-			final CellCreatorThread[] cellCreatorThreads = createAndStartCellCreatorThreads( numCellCreatorThreads );
+			final ExecutorService executorService = Executors.newFixedThreadPool( numCellCreatorThreads );
 			try
 			{
 				// write the image
-				writeViewToHdf5PartitionFile( img, timepointIdPartition, setupIdPartition, mipmapInfo, writeMipmapInfo, deflate, writerQueue, cellCreatorThreads, loopbackHeuristic, afterEachPlane, progressWriter );
+				writeViewToHdf5PartitionFile( img, timepointIdPartition, setupIdPartition, mipmapInfo, writeMipmapInfo, deflate, writerQueue, executorService, numCellCreatorThreads, loopbackHeuristic, afterEachPlane, progressWriter );
 			}
 			finally
 			{
-				stopCellCreatorThreads( cellCreatorThreads );
+				executorService.shutdown();
 			}
 		}
 		finally
@@ -569,6 +563,57 @@ public class WriteSequenceToHdf5
 		}
 	}
 
+	/*
+	 TODO:
+	 	This approximately implements DatasetIO in terms of IHDFAccess.
+	 	It works with how DatasetIO is currently used,
+	 	but for general usage, IHDF5Access must be revised.
+	 */
+	static class HDF5DatasetIO implements DatasetIO< Object, UnsignedShortType >
+	{
+		private final IHDF5Access writerQueue;
+		private final ViewId viewIdPartition;
+		private final HDF5IntStorageFeatures storage;
+		private final LoopBackImageLoader loopback;
+
+		public HDF5DatasetIO( final IHDF5Access writerQueue, final ViewId viewIdPartition, final HDF5IntStorageFeatures storage, final LoopBackImageLoader loopback )
+		{
+			this.writerQueue = writerQueue;
+			this.viewIdPartition = viewIdPartition;
+			this.storage = storage;
+			this.loopback = loopback;
+		}
+
+		@Override
+		public Object createDataset( final int level, final long[] dimensions, final int[] blockSize )
+		{
+			final String path = Util.getCellsPath( viewIdPartition, level );
+			writerQueue.createAndOpenDataset( path, dimensions.clone(), blockSize.clone(), storage );
+			return null;
+		}
+
+		@Override
+		public void writeBlock( final Object dataset, final Block< UnsignedShortType > dataBlock )
+		{
+			final SingleCellArrayImg< UnsignedShortType, ? > img = dataBlock.getData();
+			final long[] blockDimensions = Intervals.dimensionsAsLongArray( img );
+			final long[] offset = Intervals.minAsLongArray( img );
+			writerQueue.writeBlockWithOffset( Cast.unchecked( img.getStorageArray() ), blockDimensions, offset );
+		}
+
+		@Override
+		public void flush( final Object dataset )
+		{
+			writerQueue.closeDataset();
+		}
+
+		@Override
+		public RandomAccessibleInterval< UnsignedShortType > getImage( final int level )
+		{
+			return loopback.getSetupImgLoader( viewIdPartition.getViewSetupId() ).getImage( viewIdPartition.getTimePointId(), level );
+		}
+	}
+
 	/**
 	 * Write a single view to a hdf5 partition file, in a chunked, mipmaped
 	 * representation. Note that the specified view must not already exist in
@@ -594,9 +639,10 @@ public class WriteSequenceToHdf5
 	 *            whether to compress the data with the HDF5 DEFLATE filter.
 	 * @param writerQueue
 	 *            block writing tasks are enqueued here.
-	 * @param cellCreatorThreads
-	 *            threads used for creating (possibly down-sampled) blocks of
+	 * @param executorService
+	 *            executor used for creating (possibly down-sampled) blocks of
 	 *            the view to be written.
+	 * @param numThreads
 	 * @param loopbackHeuristic
 	 *            heuristic to decide whether to create each resolution level by
 	 *            reading pixels from the original image or by reading back a
@@ -616,22 +662,13 @@ public class WriteSequenceToHdf5
 			final ExportMipmapInfo mipmapInfo,
 			final boolean writeMipmapInfo,
 			final boolean deflate,
-			final Hdf5BlockWriterThread writerQueue,
-			final CellCreatorThread[] cellCreatorThreads,
+			final IHDF5Access writerQueue,
+			final ExecutorService executorService, // TODO
+			final int numThreads, // TODO
 			final LoopbackHeuristic loopbackHeuristic,
 			final AfterEachPlane afterEachPlane,
 			ProgressWriter progressWriter )
 	{
-		final HDF5IntStorageFeatures storage = deflate ? HDF5IntStorageFeatures.INT_AUTO_SCALING_DEFLATE : HDF5IntStorageFeatures.INT_AUTO_SCALING;
-
-		if ( progressWriter == null )
-			progressWriter = new ProgressWriterConsole();
-
-		// for progressWriter
-		final int numTasks = mipmapInfo.getNumLevels();
-		int numCompletedTasks = 0;
-		progressWriter.setProgress( 0.0 );
-
 		// write Mipmap descriptions
 		if ( writeMipmapInfo )
 			writerQueue.writeMipmapDescription( setupIdPartition, mipmapInfo );
@@ -640,359 +677,31 @@ public class WriteSequenceToHdf5
 		// h5 for generating low-resolution versions.
 		final LoopBackImageLoader loopback = ( loopbackHeuristic == null ) ? null : LoopBackImageLoader.create( writerQueue.getIHDF5Writer(), timepointIdPartition, setupIdPartition, img );
 
-		// write image data for all views to the HDF5 file
-		final int n = 3;
-		final long[] dimensions = new long[ n ];
-
-		final int[][] resolutions = mipmapInfo.getExportResolutions();
-		final int[][] subdivisions = mipmapInfo.getSubdivisions();
-		final int numLevels = mipmapInfo.getNumLevels();
-
-		for ( int level = 0; level < numLevels; ++level )
-		{
-			progressWriter.out().println( "writing level " + level );
-
-			final RandomAccessibleInterval< UnsignedShortType > sourceImg;
-			final int[] factor;
-			final boolean useLoopBack;
-			if ( loopbackHeuristic == null )
-			{
-				sourceImg = img;
-				factor = resolutions[ level ];
-				useLoopBack = false;
-			}
-			else
-			{
-				// Are downsampling factors a multiple of a level that we have
-				// already written?
-				int[] factorsToPreviousLevel = null;
-				int previousLevel = -1;
-				A: for ( int l = level - 1; l >= 0; --l )
-				{
-					final int[] f = new int[ n ];
-					for ( int d = 0; d < n; ++d )
-					{
-						f[ d ] = resolutions[ level ][ d ] / resolutions[ l ][ d ];
-						if ( f[ d ] * resolutions[ l ][ d ] != resolutions[ level ][ d ] )
-							continue A;
-					}
-					factorsToPreviousLevel = f;
-					previousLevel = l;
-					break;
-				}
-				// Now, if previousLevel >= 0 we can use loopback ImgLoader on
-				// previousLevel and downsample with factorsToPreviousLevel.
-				//
-				// whether it makes sense to actually do so is determined by a
-				// heuristic based on the following considerations:
-				// * if downsampling a lot over original image, the cost of
-				//   reading images back from hdf5 outweighs the cost of
-				//   accessing and averaging original pixels.
-				// * original image may already be cached (for example when
-				//   exporting an ImageJ virtual stack. To compute blocks
-				//   that downsample a lot in Z, many planes of the virtual
-				//   stack need to be accessed leading to cache thrashing if
-				//   individual planes are very large.
-
-				useLoopBack = loopbackHeuristic.decide( img, resolutions[ level ], previousLevel, factorsToPreviousLevel, subdivisions[ level ] );
-				if ( useLoopBack )
-				{
-					sourceImg = loopback.getSetupImgLoader( setupIdPartition ).getImage( timepointIdPartition, previousLevel );
-					factor = factorsToPreviousLevel;
-				}
-				else
-				{
-					sourceImg = img;
-					factor = resolutions[ level ];
-				}
-			}
-
-			sourceImg.dimensions( dimensions );
-			final boolean fullResolution = ( factor[ 0 ] == 1 && factor[ 1 ] == 1 && factor[ 2 ] == 1 );
-			long size = 1;
-			if ( !fullResolution )
-			{
-				for ( int d = 0; d < n; ++d )
-				{
-					dimensions[ d ] = Math.max( dimensions[ d ] / factor[ d ], 1 );
-					size *= factor[ d ];
-				}
-			}
-			final double scale = 1.0 / size;
-
-			final long[] minRequiredInput = new long[ n ];
-			final long[] maxRequiredInput = new long[ n ];
-			sourceImg.min( minRequiredInput );
-			for ( int d = 0; d < n; ++d )
-				maxRequiredInput[ d ] = minRequiredInput[ d ] + dimensions[ d ] * factor[ d ] - 1;
-			final RandomAccessibleInterval< UnsignedShortType > extendedImg = Views.interval( Views.extendBorder( sourceImg ), new FinalInterval( minRequiredInput, maxRequiredInput ) );
-
-			final int[] cellDimensions = subdivisions[ level ];
-			final ViewId viewIdPartition = new ViewId( timepointIdPartition, setupIdPartition );
-			final String path = Util.getCellsPath( viewIdPartition, level );
-			writerQueue.createAndOpenDataset( path, dimensions.clone(), cellDimensions.clone(), storage );
-
-			final long[] numCells = new long[ n ];
-			final int[] borderSize = new int[ n ];
-			final long[] minCell = new long[ n ];
-			final long[] maxCell = new long[ n ];
-			for ( int d = 0; d < n; ++d )
-			{
-				numCells[ d ] = ( dimensions[ d ] - 1 ) / cellDimensions[ d ] + 1;
-				maxCell[ d ] = numCells[ d ] - 1;
-				borderSize[ d ] = ( int ) ( dimensions[ d ] - ( numCells[ d ] - 1 ) * cellDimensions[ d ] );
-			}
-
-			ProgressWriter subProgressWriter = new SubTaskProgressWriter(
-					progressWriter, (double) numCompletedTasks / numTasks,
-					(double) (numCompletedTasks + 1) / numTasks);
-			// generate one "plane" of cells after the other to avoid cache thrashing when exporting from virtual stacks
-			for ( int lastDimCell = 0; lastDimCell < numCells[ n - 1 ]; ++lastDimCell )
-			{
-				minCell[ n - 1 ] = lastDimCell;
-				maxCell[ n - 1 ] = lastDimCell;
-				final LocalizingIntervalIterator i = new LocalizingIntervalIterator( minCell, maxCell );
-
-				final int numThreads = cellCreatorThreads.length;
-				final CountDownLatch doneSignal = new CountDownLatch( numThreads );
-				for ( int threadNum = 0; threadNum < numThreads; ++threadNum )
-				{
-					cellCreatorThreads[ threadNum ].run( new Runnable()
-					{
-						@Override
-						public void run()
-						{
-							final double[] accumulator = fullResolution ? null : new double[ cellDimensions[ 0 ] * cellDimensions[ 1 ] * cellDimensions[ 2 ] ];
-							final long[] currentCellMin = new long[ n ];
-							final long[] currentCellMax = new long[ n ];
-							final long[] currentCellDim = new long[ n ];
-							final long[] currentCellPos = new long[ n ];
-							final long[] blockMin = new long[ n ];
-							final RandomAccess< UnsignedShortType > in = extendedImg.randomAccess();
-							while ( true )
-							{
-								synchronized ( i )
-								{
-									if ( !i.hasNext() )
-										break;
-									i.fwd();
-									i.localize( currentCellPos );
-								}
-								for ( int d = 0; d < n; ++d )
-								{
-									currentCellMin[ d ] = currentCellPos[ d ] * cellDimensions[ d ];
-									blockMin[ d ] = currentCellMin[ d ] * factor[ d ];
-									final boolean isBorderCellInThisDim = ( currentCellPos[ d ] + 1 == numCells[ d ] );
-									currentCellDim[ d ] = isBorderCellInThisDim ? borderSize[ d ] : cellDimensions[ d ];
-									currentCellMax[ d ] = currentCellMin[ d ] + currentCellDim[ d ] - 1;
-								}
-
-								final ArrayImg< UnsignedShortType, ? > cell = ArrayImgs.unsignedShorts( currentCellDim );
-								if ( fullResolution )
-									copyBlock( cell.randomAccess(), currentCellDim, in, blockMin );
-								else
-									downsampleBlock( cell.cursor(), accumulator, currentCellDim, in, blockMin, factor, scale );
-
-								writerQueue.writeBlockWithOffset( ( ( ShortArray ) cell.update( null ) ).getCurrentStorageArray(), currentCellDim.clone(), currentCellMin.clone() );
-							}
-							doneSignal.countDown();
-						}
-					} );
-				}
-				try
-				{
-					doneSignal.await();
-				}
-				catch ( final InterruptedException e )
-				{
-					e.printStackTrace();
-				}
-				if ( afterEachPlane != null )
-					afterEachPlane.afterEachPlane( useLoopBack );
-
-				subProgressWriter.setProgress((double) lastDimCell / numCells[n - 1]);
-			}
-			writerQueue.closeDataset();
-			progressWriter.setProgress( ( double ) ++numCompletedTasks / numTasks );
-		}
-		if ( loopback != null )
-			loopback.close();
-	}
-
-	/**
-	 * A heuristic to decide for a given resolution level whether the source
-	 * pixels should be taken from the original image or read from a previously
-	 * written resolution level in the hdf5 file.
-	 */
-	public interface LoopbackHeuristic
-	{
-		/**
-		 * @return {@code true} if source pixels should be read back from hdf5
-		 *         file. {@code false} if source pixels should be taken from
-		 *         original image.
-		 */
-		public boolean decide(
-				final RandomAccessibleInterval< ? > originalImg,
-				final int[] factorsToOriginalImg,
-				final int previousLevel,
-				final int[] factorsToPreviousLevel,
-				final int[] chunkSize );
-	}
-
-	public interface AfterEachPlane
-	{
-		public void afterEachPlane( final boolean usedLoopBack );
-	}
-
-	/**
-	 * Simple heuristic: use loopback image loader if saving 8 times or more on
-	 * number of pixel access with respect to the original image.
-	 *
-	 * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
-	 */
-	public static class DefaultLoopbackHeuristic implements LoopbackHeuristic
-	{
-		@Override
-		public boolean decide( final RandomAccessibleInterval< ? > originalImg, final int[] factorsToOriginalImg, final int previousLevel, final int[] factorsToPreviousLevel, final int[] chunkSize )
-		{
-			if ( previousLevel < 0 )
-				return false;
-
-			if ( numElements( factorsToOriginalImg ) / numElements( factorsToPreviousLevel ) >= 8 )
-				return true;
-
-			return false;
-		}
-	}
-
-	public static int numElements( final int[] size )
-	{
-		int numElements = size[ 0 ];
-		for ( int d = 1; d < size.length; ++d )
-			numElements *= size[ d ];
-		return numElements;
-	}
-
-	public static CellCreatorThread[] createAndStartCellCreatorThreads( final int numThreads )
-	{
-		final CellCreatorThread[] cellCreatorThreads = new CellCreatorThread[ numThreads ];
-		for ( int threadNum = 0; threadNum < numThreads; ++threadNum )
-		{
-			cellCreatorThreads[ threadNum ] = new CellCreatorThread();
-			cellCreatorThreads[ threadNum ].setName( "CellCreatorThread " + threadNum );
-			cellCreatorThreads[ threadNum ].start();
-		}
-		return cellCreatorThreads;
-	}
-
-	public static void stopCellCreatorThreads( final CellCreatorThread[] cellCreatorThreads )
-	{
-		for ( final CellCreatorThread thread : cellCreatorThreads )
-			thread.interrupt();
-	}
-
-	public static class CellCreatorThread extends Thread
-	{
-		private Runnable currentTask = null;
-
-		public synchronized void run( final Runnable task )
-		{
-			currentTask = task;
-			notify();
-		}
-
-		@Override
-		public void run()
-		{
-			while ( !isInterrupted() )
-			{
-				synchronized ( this )
-				{
-					try
-					{
-						if ( currentTask == null )
-							wait();
-						else
-						{
-							currentTask.run();
-							currentTask = null;
-						}
-					}
-					catch ( final InterruptedException e )
-					{
-						break;
-					}
-				}
-			}
-
-		}
-	}
+		final DatasetIO< Object, UnsignedShortType > io = new HDF5DatasetIO(
+				writerQueue,
+				new ViewId( timepointIdPartition, setupIdPartition ),
+				deflate ? HDF5IntStorageFeatures.INT_AUTO_SCALING_DEFLATE : HDF5IntStorageFeatures.INT_AUTO_SCALING,
+				loopback );
 
-	private static < T extends RealType< T > > void copyBlock( final RandomAccess< T > out, final long[] outDim, final RandomAccess< T > in, final long[] blockMin )
-	{
-		in.setPosition( blockMin );
-		for ( out.setPosition( 0, 2 ); out.getLongPosition( 2 ) < outDim[ 2 ]; out.fwd( 2 ) )
+		try
 		{
-			for ( out.setPosition( 0, 1 ); out.getLongPosition( 1 ) < outDim[ 1 ]; out.fwd( 1 ) )
-			{
-				for ( out.setPosition( 0, 0 ); out.getLongPosition( 0 ) < outDim[ 0 ]; out.fwd( 0 ), in.fwd( 0 ) )
-				{
-					out.get().set( in.get() );
-				}
-				in.setPosition( blockMin[ 0 ], 0 );
-				in.fwd( 1 );
-			}
-			in.setPosition( blockMin[ 1 ], 1 );
-			in.fwd( 2 );
+			ExportScalePyramid.writeScalePyramid(
+					img,
+					new UnsignedShortType(),
+					mipmapInfo,
+					io,
+					executorService,
+					numThreads,
+					loopbackHeuristic,
+					afterEachPlane,
+					progressWriter );
 		}
-	}
-
-	private static < T extends RealType< T > > void downsampleBlock( final Cursor< T > out, final double[] accumulator, final long[] outDim, final RandomAccess< UnsignedShortType > randomAccess, final long[] blockMin, final int[] blockSize, final double scale )
-	{
-		final int numBlockPixels = ( int ) ( outDim[ 0 ] * outDim[ 1 ] * outDim[ 2 ] );
-		Arrays.fill( accumulator, 0, numBlockPixels, 0 );
-
-		randomAccess.setPosition( blockMin );
-
-		final int ox = ( int ) outDim[ 0 ];
-		final int oy = ( int ) outDim[ 1 ];
-		final int oz = ( int ) outDim[ 2 ];
-
-		final int sx = ox * blockSize[ 0 ];
-		final int sy = oy * blockSize[ 1 ];
-		final int sz = oz * blockSize[ 2 ];
-
-		int i = 0;
-		for ( int z = 0, bz = 0; z < sz; ++z )
+		catch ( IOException e )
 		{
-			for ( int y = 0, by = 0; y < sy; ++y )
-			{
-				for ( int x = 0, bx = 0; x < sx; ++x )
-				{
-					accumulator[ i ] += randomAccess.get().getRealDouble();
-					randomAccess.fwd( 0 );
-					if ( ++bx == blockSize[ 0 ] )
-					{
-						bx = 0;
-						++i;
-					}
-				}
-				randomAccess.move( -sx, 0 );
-				randomAccess.fwd( 1 );
-				if ( ++by == blockSize[ 1 ] )
-					by = 0;
-				else
-					i -= ox;
-			}
-			randomAccess.move( -sy, 1 );
-			randomAccess.fwd( 2 );
-			if ( ++bz == blockSize[ 2 ] )
-				bz = 0;
-			else
-				i -= ox * oy;
+			e.printStackTrace();
 		}
 
-		for ( int j = 0; j < numBlockPixels; ++j )
-			out.next().setReal( accumulator[ j ] * scale );
+		if ( loopback != null )
+			loopback.close();
 	}
 }
diff --git a/src/main/java/bdv/export/n5/WriteSequenceToN5.java b/src/main/java/bdv/export/n5/WriteSequenceToN5.java
new file mode 100644
index 0000000000000000000000000000000000000000..9de577c7c4275ec4ec957b5a5d2b1ce457b56a1f
--- /dev/null
+++ b/src/main/java/bdv/export/n5/WriteSequenceToN5.java
@@ -0,0 +1,371 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.export.n5;
+
+import bdv.export.ExportMipmapInfo;
+import bdv.export.ExportScalePyramid;
+import bdv.export.ProgressWriter;
+import bdv.export.ProgressWriterNull;
+import bdv.export.SubTaskProgressWriter;
+import bdv.export.ExportScalePyramid.AfterEachPlane;
+import bdv.export.ExportScalePyramid.LoopbackHeuristic;
+import bdv.img.cache.SimpleCacheArrayLoader;
+import bdv.img.n5.N5ImageLoader;
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import mpicbg.spim.data.generic.sequence.AbstractSequenceDescription;
+import mpicbg.spim.data.generic.sequence.BasicImgLoader;
+import mpicbg.spim.data.generic.sequence.BasicSetupImgLoader;
+import mpicbg.spim.data.generic.sequence.BasicViewSetup;
+import mpicbg.spim.data.sequence.TimePoint;
+import mpicbg.spim.data.sequence.ViewId;
+import mpicbg.spim.data.sequence.VoxelDimensions;
+import net.imglib2.RandomAccessibleInterval;
+import net.imglib2.cache.img.ReadOnlyCachedCellImgFactory;
+import net.imglib2.img.cell.Cell;
+import net.imglib2.img.cell.CellGrid;
+import net.imglib2.type.NativeType;
+import net.imglib2.type.numeric.RealType;
+import net.imglib2.util.Cast;
+import org.janelia.saalfeldlab.n5.ByteArrayDataBlock;
+import org.janelia.saalfeldlab.n5.Compression;
+import org.janelia.saalfeldlab.n5.DataBlock;
+import org.janelia.saalfeldlab.n5.DataType;
+import org.janelia.saalfeldlab.n5.DatasetAttributes;
+import org.janelia.saalfeldlab.n5.DoubleArrayDataBlock;
+import org.janelia.saalfeldlab.n5.FloatArrayDataBlock;
+import org.janelia.saalfeldlab.n5.IntArrayDataBlock;
+import org.janelia.saalfeldlab.n5.LongArrayDataBlock;
+import org.janelia.saalfeldlab.n5.N5FSWriter;
+import org.janelia.saalfeldlab.n5.N5Writer;
+import org.janelia.saalfeldlab.n5.ShortArrayDataBlock;
+import org.janelia.saalfeldlab.n5.imglib2.N5Utils;
+
+import static bdv.img.n5.BdvN5Format.DATA_TYPE_KEY;
+import static bdv.img.n5.BdvN5Format.DOWNSAMPLING_FACTORS_KEY;
+import static bdv.img.n5.BdvN5Format.getPathName;
+import static net.imglib2.cache.img.ReadOnlyCachedCellImgOptions.options;
+
+/**
+ * @author Tobias Pietzsch
+ * @author John Bogovic
+ */
+public class WriteSequenceToN5
+{
+	private static final String MULTI_SCALE_KEY = "multiScale";
+	private static final String RESOLUTION_KEY = "resolution";
+
+	/**
+	 * Create a n5 group containing image data from all views and all
+	 * timepoints in a chunked, mipmaped representation.
+	 *
+	 * @param seq
+	 *            description of the sequence to be stored as hdf5. (The
+	 *            {@link AbstractSequenceDescription} contains the number of
+	 *            setups and timepoints as well as an {@link BasicImgLoader}
+	 *            that provides the image data, Registration information is not
+	 *            needed here, that will go into the accompanying xml).
+	 * @param perSetupMipmapInfo
+	 *            this maps from setup {@link BasicViewSetup#getId() id} to
+	 *            {@link ExportMipmapInfo} for that setup. The
+	 *            {@link ExportMipmapInfo} contains for each mipmap level, the
+	 *            subsampling factors and subdivision block sizes.
+	 * @param compression
+	 *            n5 compression scheme.
+	 * @param n5File
+	 *            n5 root.
+	 * @param loopbackHeuristic
+	 *            heuristic to decide whether to create each resolution level by
+	 *            reading pixels from the original image or by reading back a
+	 *            finer resolution level already written to the hdf5. may be
+	 *            null (in this case always use the original image).
+	 * @param afterEachPlane
+	 *            this is called after each "plane of chunks" is written, giving
+	 *            the opportunity to clear caches, etc.
+	 * @param numCellCreatorThreads
+	 *            The number of threads that will be instantiated to generate
+	 *            cell data. Must be at least 1. (In addition the cell creator
+	 *            threads there is one writer thread that saves the generated
+	 *            data to HDF5.)
+	 * @param progressWriter
+	 *            completion ratio and status output will be directed here.
+	 */
+	public static void writeN5File(
+			final AbstractSequenceDescription< ?, ?, ? > seq,
+			final Map< Integer, ExportMipmapInfo > perSetupMipmapInfo,
+			final Compression compression,
+			final File n5File,
+			final LoopbackHeuristic loopbackHeuristic,
+			final AfterEachPlane afterEachPlane,
+			final int numCellCreatorThreads,
+			ProgressWriter progressWriter ) throws IOException
+	{
+		if ( progressWriter == null )
+			progressWriter = new ProgressWriterNull();
+		progressWriter.setProgress( 0 );
+
+		final BasicImgLoader imgLoader = seq.getImgLoader();
+
+		for ( final BasicViewSetup setup : seq.getViewSetupsOrdered() )
+		{
+			final Object type = imgLoader.getSetupImgLoader( setup.getId() ).getImageType();
+			if ( !( type instanceof RealType &&
+					type instanceof NativeType &&
+					N5Utils.dataType( Cast.unchecked( type ) ) != null ) )
+				throw new IllegalArgumentException( "Unsupported pixel type: " + type.getClass().getSimpleName() );
+		}
+
+		final List< Integer > timepointIds = seq.getTimePoints().getTimePointsOrdered().stream()
+				.map( TimePoint::getId )
+				.collect( Collectors.toList() );
+		final List< Integer > setupIds = seq.getViewSetupsOrdered().stream()
+				.map( BasicViewSetup::getId )
+				.collect( Collectors.toList() );
+
+		N5Writer n5 = new N5FSWriter( n5File.getAbsolutePath() );
+
+		// write Mipmap descriptions
+		for ( final int setupId : setupIds )
+		{
+			final String pathName = getPathName( setupId );
+			final int[][] downsamplingFactors = perSetupMipmapInfo.get( setupId ).getExportResolutions();
+			final DataType dataType = N5Utils.dataType( Cast.unchecked( imgLoader.getSetupImgLoader( setupId ).getImageType() ) );
+			n5.createGroup( pathName );
+			n5.setAttribute( pathName, DOWNSAMPLING_FACTORS_KEY, downsamplingFactors );
+			n5.setAttribute( pathName, DATA_TYPE_KEY, dataType );
+		}
+
+
+		// calculate number of tasks for progressWriter
+		int numTasks = 0; // first task is for writing mipmap descriptions etc...
+		for ( final int timepointIdSequence : timepointIds )
+			for ( final int setupIdSequence : setupIds )
+				if ( seq.getViewDescriptions().get( new ViewId( timepointIdSequence, setupIdSequence ) ).isPresent() )
+					numTasks++;
+		int numCompletedTasks = 0;
+
+		final ExecutorService executorService = Executors.newFixedThreadPool( numCellCreatorThreads );
+		try
+		{
+			// write image data for all views
+			final int numTimepoints = timepointIds.size();
+			int timepointIndex = 0;
+			for ( final int timepointId : timepointIds )
+			{
+				progressWriter.out().printf( "proccessing timepoint %d / %d\n", ++timepointIndex, numTimepoints );
+
+				// assemble the viewsetups that are present in this timepoint
+				final ArrayList< Integer > setupsTimePoint = new ArrayList<>();
+				for ( final int setupId : setupIds )
+					if ( seq.getViewDescriptions().get( new ViewId( timepointId, setupId ) ).isPresent() )
+						setupsTimePoint.add( setupId );
+
+				final int numSetups = setupsTimePoint.size();
+				int setupIndex = 0;
+				for ( final int setupId : setupsTimePoint )
+				{
+					progressWriter.out().printf( "proccessing setup %d / %d\n", ++setupIndex, numSetups );
+
+					final ExportMipmapInfo mipmapInfo = perSetupMipmapInfo.get( setupId );
+					final double startCompletionRatio = ( double ) numCompletedTasks++ / numTasks;
+					final double endCompletionRatio = ( double ) numCompletedTasks / numTasks;
+					final ProgressWriter subProgressWriter = new SubTaskProgressWriter( progressWriter, startCompletionRatio, endCompletionRatio );
+					writeScalePyramid(
+							n5, compression,
+							imgLoader, setupId, timepointId, mipmapInfo,
+							executorService, numCellCreatorThreads,
+							loopbackHeuristic, afterEachPlane, subProgressWriter );
+
+
+					// additional attributes for paintera compatibility
+					final String pathName = getPathName( setupId, timepointId );
+					n5.createGroup( pathName );
+					n5.setAttribute( pathName, MULTI_SCALE_KEY, true );
+					final VoxelDimensions voxelSize = seq.getViewSetups().get( setupId ).getVoxelSize();
+					if ( voxelSize != null )
+					{
+						final double[] resolution = new double[ voxelSize.numDimensions() ];
+						voxelSize.dimensions( resolution );
+						n5.setAttribute( pathName, RESOLUTION_KEY, resolution );
+					}
+					final int[][] downsamplingFactors = perSetupMipmapInfo.get( setupId ).getExportResolutions();
+					for( int l = 0; l < downsamplingFactors.length; ++l )
+						n5.setAttribute( getPathName( setupId, timepointId, l ), DOWNSAMPLING_FACTORS_KEY, downsamplingFactors[ l ] );
+				}
+			}
+		}
+		finally
+		{
+			executorService.shutdown();
+		}
+
+		progressWriter.setProgress( 1.0 );
+	}
+
+	static < T extends RealType< T > & NativeType< T > > void writeScalePyramid(
+			final N5Writer n5,
+			final Compression compression,
+			final BasicImgLoader imgLoader,
+			final int setupId,
+			final int timepointId,
+			final ExportMipmapInfo mipmapInfo,
+			final ExecutorService executorService,
+			final int numThreads,
+			final LoopbackHeuristic loopbackHeuristic,
+			final AfterEachPlane afterEachPlane,
+			ProgressWriter progressWriter ) throws IOException
+	{
+		final BasicSetupImgLoader< T > setupImgLoader = Cast.unchecked( imgLoader.getSetupImgLoader( setupId ) );
+		final RandomAccessibleInterval< T > img = setupImgLoader.getImage( timepointId );
+		final T type = setupImgLoader.getImageType();
+		final N5DatasetIO< T > io = new N5DatasetIO<>( n5, compression, setupId, timepointId, type );
+		ExportScalePyramid.writeScalePyramid(
+				img, type, mipmapInfo, io,
+				executorService, numThreads,
+				loopbackHeuristic, afterEachPlane, progressWriter );
+	}
+
+	static class N5Dataset
+	{
+		final String pathName;
+		final DatasetAttributes attributes;
+
+		public N5Dataset( final String pathName, final DatasetAttributes attributes )
+		{
+			this.pathName = pathName;
+			this.attributes = attributes;
+		}
+	}
+
+	static class N5DatasetIO< T extends RealType< T > & NativeType< T > > implements ExportScalePyramid.DatasetIO< N5Dataset, T >
+	{
+		private final N5Writer n5;
+		private final Compression compression;
+		private final int setupId;
+		private final int timepointId;
+		private final DataType dataType;
+		private final T type;
+		private final Function< ExportScalePyramid.Block< T >, DataBlock< ? > > getDataBlock;
+
+		public N5DatasetIO( final N5Writer n5, final Compression compression, final int setupId, final int timepointId, final T type )
+		{
+			this.n5 = n5;
+			this.compression = compression;
+			this.setupId = setupId;
+			this.timepointId = timepointId;
+			this.dataType = N5Utils.dataType( type );
+			this.type = type;
+
+			switch ( dataType )
+			{
+			case UINT8:
+				getDataBlock = b -> new ByteArrayDataBlock( b.getSize(), b.getGridPosition(), Cast.unchecked( b.getData().getStorageArray() ) );
+				break;
+			case UINT16:
+				getDataBlock = b -> new ShortArrayDataBlock( b.getSize(), b.getGridPosition(), Cast.unchecked( b.getData().getStorageArray() ) );
+				break;
+			case UINT32:
+				getDataBlock = b -> new IntArrayDataBlock( b.getSize(), b.getGridPosition(), Cast.unchecked( b.getData().getStorageArray() ) );
+				break;
+			case UINT64:
+				getDataBlock = b -> new LongArrayDataBlock( b.getSize(), b.getGridPosition(), Cast.unchecked( b.getData().getStorageArray() ) );
+				break;
+			case INT8:
+				getDataBlock = b -> new ByteArrayDataBlock( b.getSize(), b.getGridPosition(), Cast.unchecked( b.getData().getStorageArray() ) );
+				break;
+			case INT16:
+				getDataBlock = b -> new ShortArrayDataBlock( b.getSize(), b.getGridPosition(), Cast.unchecked( b.getData().getStorageArray() ) );
+				break;
+			case INT32:
+				getDataBlock = b -> new IntArrayDataBlock( b.getSize(), b.getGridPosition(), Cast.unchecked( b.getData().getStorageArray() ) );
+				break;
+			case INT64:
+				getDataBlock = b -> new LongArrayDataBlock( b.getSize(), b.getGridPosition(), Cast.unchecked( b.getData().getStorageArray() ) );
+				break;
+			case FLOAT32:
+				getDataBlock = b -> new FloatArrayDataBlock( b.getSize(), b.getGridPosition(), Cast.unchecked( b.getData().getStorageArray() ) );
+				break;
+			case FLOAT64:
+				getDataBlock = b -> new DoubleArrayDataBlock( b.getSize(), b.getGridPosition(), Cast.unchecked( b.getData().getStorageArray() ) );
+				break;
+			default:
+				throw new IllegalArgumentException();
+			}
+		}
+
+		@Override
+		public N5Dataset createDataset( final int level, final long[] dimensions, final int[] blockSize ) throws IOException
+		{
+			final String pathName = getPathName( setupId, timepointId, level );
+			n5.createDataset( pathName, dimensions, blockSize, dataType, compression );
+			final DatasetAttributes attributes = n5.getDatasetAttributes( pathName );
+			return new N5Dataset( pathName, attributes );
+		}
+
+		@Override
+		public void writeBlock( final N5Dataset dataset, final ExportScalePyramid.Block< T > dataBlock ) throws IOException
+		{
+			n5.writeBlock( dataset.pathName, dataset.attributes, getDataBlock.apply( dataBlock ) );
+		}
+
+		@Override
+		public void flush( final N5Dataset dataset )
+		{}
+
+		@Override
+		public RandomAccessibleInterval< T > getImage( final int level ) throws IOException
+		{
+			final String pathName = getPathName( setupId, timepointId, level );
+			final DatasetAttributes attributes = n5.getDatasetAttributes( pathName );
+			final long[] dimensions = attributes.getDimensions();
+			final int[] cellDimensions = attributes.getBlockSize();
+			final CellGrid grid = new CellGrid( dimensions, cellDimensions );
+			final SimpleCacheArrayLoader< ? > cacheArrayLoader = N5ImageLoader.createCacheArrayLoader( n5, pathName );
+			return new ReadOnlyCachedCellImgFactory().createWithCacheLoader(
+					dimensions, type,
+					key -> {
+						final int n = grid.numDimensions();
+						final long[] cellMin = new long[ n ];
+						final int[] cellDims = new int[ n ];
+						final long[] cellGridPosition = new long[ n ];
+						grid.getCellDimensions( key, cellMin, cellDims );
+						grid.getCellGridPositionFlat( key, cellGridPosition );
+						return new Cell<>( cellDims, cellMin, cacheArrayLoader.loadArray( cellGridPosition ) );
+					},
+					options().cellDimensions( cellDimensions ) );
+		}
+	}
+}
diff --git a/src/main/java/bdv/img/cache/CacheArrayLoader.java b/src/main/java/bdv/img/cache/CacheArrayLoader.java
index 800ff355b8e4116ade49aee3ffee61d59790799c..e78905e038e86e28d5981a030dd264015f8d292a 100644
--- a/src/main/java/bdv/img/cache/CacheArrayLoader.java
+++ b/src/main/java/bdv/img/cache/CacheArrayLoader.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -30,7 +29,6 @@
 package bdv.img.cache;
 
 import bdv.ViewerImgLoader;
-import bdv.img.catmaid.CatmaidImageLoader;
 import net.imglib2.img.basictypeaccess.volatiles.VolatileAccess;
 import net.imglib2.img.basictypeaccess.volatiles.VolatileArrayDataAccess;
 import net.imglib2.img.basictypeaccess.volatiles.array.DirtyVolatileByteArray;
@@ -58,7 +56,7 @@ import net.imglib2.type.NativeType;
  *            type of access to cell data, currently always a
  *            {@link VolatileAccess}.
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
 public interface CacheArrayLoader< A >
 {
@@ -69,7 +67,7 @@ public interface CacheArrayLoader< A >
 	 *
 	 * @return number of bytes required to store one element.
 	 */
-	public default int getBytesPerElement()
+	default int getBytesPerElement()
 	{
 		return 1;
 	}
@@ -103,7 +101,7 @@ public interface CacheArrayLoader< A >
 	 *
 	 * @return an {@link EmptyArrayCreator} for {@code A} or null.
 	 */
-	public default EmptyArrayCreator< A > getEmptyArrayCreator()
+	default EmptyArrayCreator< A > getEmptyArrayCreator()
 	{
 		return null;
 	}
@@ -128,10 +126,10 @@ public interface CacheArrayLoader< A >
 	 * back-end. You do not need to be able to load blocks of arbitrary sizes
 	 * and offsets here -- just the ones that you will use from the images
 	 * returned by your {@link ViewerImgLoader}. For an example, look at
-	 * {@link CatmaidImageLoader}. There, the blockDimensions are defined in the
+	 * {@code CatmaidImageLoader}. There, the blockDimensions are defined in the
 	 * constructor, according to the tile size of the data set. These
 	 * blockDimensions are then used for every image that the
-	 * {@link CatmaidImageLoader} provides. Therefore, all calls to
+	 * {@code CatmaidImageLoader} provides. Therefore, all calls to
 	 * {@link #loadArray(int, int, int, int[], long[])} will have predictable
 	 * {@code dimensions} (corresponding to tile size of the data set) and
 	 * {@code min} offsets (multiples of the tile size).
@@ -156,5 +154,6 @@ public interface CacheArrayLoader< A >
 	 *            the min coordinate of the block in the stack (in voxels).
 	 * @return loaded cell data.
 	 */
-	public A loadArray( final int timepoint, final int setup, final int level, int[] dimensions, long[] min ) throws InterruptedException;
+	// TODO: It would make more sense to throw IOException here. Declare both IOException and InterruptedException. Throw IOException in bdv-core implementations.
+	A loadArray( final int timepoint, final int setup, final int level, int[] dimensions, long[] min ) throws InterruptedException;
 }
diff --git a/src/main/java/bdv/img/cache/CreateInvalidVolatileCell.java b/src/main/java/bdv/img/cache/CreateInvalidVolatileCell.java
index 30ea961909ccbf37fd3d55a47063a802c254ba1d..58280eee92048e1b247f913669ba49dd951186f3 100644
--- a/src/main/java/bdv/img/cache/CreateInvalidVolatileCell.java
+++ b/src/main/java/bdv/img/cache/CreateInvalidVolatileCell.java
@@ -1,3 +1,31 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
 package bdv.img.cache;
 
 import net.imglib2.cache.volatiles.CreateInvalid;
diff --git a/src/main/java/bdv/img/cache/DefaultEmptyArrayCreator.java b/src/main/java/bdv/img/cache/DefaultEmptyArrayCreator.java
index 1ea403e0537b95c05d4e6b3a496eec1a56e62e34..0cf9dbc4507f89c4eb978c7eb77878daa6d89170 100644
--- a/src/main/java/bdv/img/cache/DefaultEmptyArrayCreator.java
+++ b/src/main/java/bdv/img/cache/DefaultEmptyArrayCreator.java
@@ -1,3 +1,31 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
 package bdv.img.cache;
 
 import net.imglib2.img.basictypeaccess.volatiles.VolatileArrayDataAccess;
diff --git a/src/main/java/bdv/img/cache/EmptyArrayCreator.java b/src/main/java/bdv/img/cache/EmptyArrayCreator.java
index 208c9ada795864438f082d098360b2b9751c4e58..cb200ead32891cfaac413326cf6e25bde5795fa2 100644
--- a/src/main/java/bdv/img/cache/EmptyArrayCreator.java
+++ b/src/main/java/bdv/img/cache/EmptyArrayCreator.java
@@ -1,3 +1,31 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
 package bdv.img.cache;
 
 import net.imglib2.img.basictypeaccess.AccessFlags;
@@ -21,9 +49,9 @@ import net.imglib2.type.PrimitiveType;
  */
 public interface EmptyArrayCreator< A >
 {
-	public A getEmptyArray( final long numEntities );
+	A getEmptyArray( final long numEntities );
 
-	public static < A extends VolatileArrayDataAccess< A > > EmptyArrayCreator< A > get(
+	static < A extends VolatileArrayDataAccess< A > > EmptyArrayCreator< A > get(
 			final PrimitiveType primitiveType,
 			final boolean dirty )
 	{
diff --git a/src/main/java/bdv/img/cache/SimpleCacheArrayLoader.java b/src/main/java/bdv/img/cache/SimpleCacheArrayLoader.java
new file mode 100644
index 0000000000000000000000000000000000000000..30922c3babc41a838c97945b7624d16493e777f1
--- /dev/null
+++ b/src/main/java/bdv/img/cache/SimpleCacheArrayLoader.java
@@ -0,0 +1,130 @@
+/*
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.img.cache;
+
+import java.io.IOException;
+
+import net.imglib2.img.basictypeaccess.volatiles.VolatileAccess;
+import net.imglib2.img.basictypeaccess.volatiles.VolatileArrayDataAccess;
+import net.imglib2.img.basictypeaccess.volatiles.array.DirtyVolatileByteArray;
+import net.imglib2.img.basictypeaccess.volatiles.array.DirtyVolatileCharArray;
+import net.imglib2.img.basictypeaccess.volatiles.array.DirtyVolatileDoubleArray;
+import net.imglib2.img.basictypeaccess.volatiles.array.DirtyVolatileFloatArray;
+import net.imglib2.img.basictypeaccess.volatiles.array.DirtyVolatileIntArray;
+import net.imglib2.img.basictypeaccess.volatiles.array.DirtyVolatileLongArray;
+import net.imglib2.img.basictypeaccess.volatiles.array.DirtyVolatileShortArray;
+import net.imglib2.img.basictypeaccess.volatiles.array.VolatileByteArray;
+import net.imglib2.img.basictypeaccess.volatiles.array.VolatileCharArray;
+import net.imglib2.img.basictypeaccess.volatiles.array.VolatileDoubleArray;
+import net.imglib2.img.basictypeaccess.volatiles.array.VolatileFloatArray;
+import net.imglib2.img.basictypeaccess.volatiles.array.VolatileIntArray;
+import net.imglib2.img.basictypeaccess.volatiles.array.VolatileLongArray;
+import net.imglib2.img.basictypeaccess.volatiles.array.VolatileShortArray;
+import net.imglib2.img.cell.CellGrid;
+import net.imglib2.type.NativeType;
+
+/**
+ * Provider of volatile {@link net.imglib2.img.cell.Cell} data. This is
+ * implemented by data back-ends to the {@link VolatileGlobalCellCache}.
+ * <p>
+ * {@code SimpleCacheArrayLoader} is supposed to load data one specific image.
+ * {@code loadArray()} will not get information about which timepoint,
+ * resolution level, etc a requested block belongs to, and also the appropriate
+ * block size is supposed to be known.
+ * <p>
+ * This is in contrast to {@link CacheArrayLoader}, where all information to
+ * identify a particular block in a whole dataset is provided. Whether it makes
+ * more sense to implement {@code CacheArrayLoader} or
+ * {@code SimpleCacheArrayLoader} depends on the particular back-end.
+ *
+ * @param <A>
+ *            type of access to cell data, currently always a
+ *            {@link VolatileAccess}.
+ *
+ * @author Tobias Pietzsch
+ */
+public interface SimpleCacheArrayLoader< A >
+{
+	/**
+	 * Implementing classes must override this if {@code A} is not a standard
+	 * {@link VolatileArrayDataAccess} type. The default implementation returns
+	 * {@code null}, which will let
+	 * {@link CreateInvalidVolatileCell#get(CellGrid, NativeType, boolean)
+	 * CreateInvalidVolatileCell.get(...)} try to figure out the appropriate
+	 * {@link DefaultEmptyArrayCreator}.
+	 * <p>
+	 * Default access types are
+	 * </p>
+	 * <ul>
+	 * <li>{@link DirtyVolatileByteArray}</li>
+	 * <li>{@link VolatileByteArray}</li>
+	 * <li>{@link DirtyVolatileCharArray}</li>
+	 * <li>{@link VolatileCharArray}</li>
+	 * <li>{@link DirtyVolatileDoubleArray}</li>
+	 * <li>{@link VolatileDoubleArray}</li>
+	 * <li>{@link DirtyVolatileFloatArray}</li>
+	 * <li>{@link VolatileFloatArray}</li>
+	 * <li>{@link DirtyVolatileIntArray}</li>
+	 * <li>{@link VolatileIntArray}</li>
+	 * <li>{@link DirtyVolatileLongArray}</li>
+	 * <li>{@link VolatileLongArray}</li>
+	 * <li>{@link DirtyVolatileShortArray}</li>
+	 * <li>{@link VolatileShortArray}</li>
+	 * </ul>
+	 *
+	 * @return an {@link EmptyArrayCreator} for {@code A} or null.
+	 */
+	default EmptyArrayCreator< A > getEmptyArrayCreator()
+	{
+		return null;
+	}
+
+	/**
+	 * Load cell data into memory. This method blocks until data is successfully
+	 * loaded. If it completes normally, the returned data is always valid. If
+	 * anything goes wrong, an {@link IOException} is thrown.
+	 * <p>
+	 * {@code SimpleCacheArrayLoader} is supposed to load data one specific
+	 * image. {@code loadArray()} will not get information about which
+	 * timepoint, resolution level, etc a requested block belongs to. Also the
+	 * appropriate block size is supposed to be known to the
+	 * {@code SimpleCacheArrayLoader}.
+	 * <p>
+	 * This is in contrast to
+	 * {@link CacheArrayLoader#loadArray(int, int, int, int[], long[])}, where
+	 * all information to identify a particular block in a whole dataset is
+	 * provided.
+	 *
+	 * @param gridPosition
+	 *            the coordinate of the cell in the cell grid.
+	 *
+	 * @return loaded cell data.
+	 */
+	A loadArray( long[] gridPosition ) throws IOException;
+}
diff --git a/src/main/java/bdv/img/cache/VolatileCachedCellImg.java b/src/main/java/bdv/img/cache/VolatileCachedCellImg.java
index 81e6c2973ba022683a7458c99cacc4cca9072b05..798fdffc5a5c7d3c5537a697611bb68344b73d37 100644
--- a/src/main/java/bdv/img/cache/VolatileCachedCellImg.java
+++ b/src/main/java/bdv/img/cache/VolatileCachedCellImg.java
@@ -1,3 +1,31 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
 package bdv.img.cache;
 
 import java.util.function.Function;
diff --git a/src/main/java/bdv/img/cache/VolatileGlobalCellCache.java b/src/main/java/bdv/img/cache/VolatileGlobalCellCache.java
index 63eddd7de0821d191c1f699c805c0a59256793a4..b39668a256545aedea602f29bd0c633a1ec80bb8 100644
--- a/src/main/java/bdv/img/cache/VolatileGlobalCellCache.java
+++ b/src/main/java/bdv/img/cache/VolatileGlobalCellCache.java
@@ -1,19 +1,18 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
- *
+ * 
  * 1. Redistributions of source code must retain the above copyright notice,
  *    this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
  *    this list of conditions and the following disclaimer in the documentation
  *    and/or other materials provided with the distribution.
- *
+ * 
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
@@ -202,22 +201,63 @@ public class VolatileGlobalCellCache implements CacheControl
 			final CacheArrayLoader< A > cacheArrayLoader,
 			final T type )
 	{
-		final CacheLoader< Long, Cell< ? > > loader = new CacheLoader< Long, Cell< ? > >()
-		{
-			@Override
-			public Cell< A > get( final Long key ) throws Exception
-			{
-				final int n = grid.numDimensions();
-				final long[] cellMin = new long[ n ];
-				final int[] cellDims = new int[ n ];
-				grid.getCellDimensions( key, cellMin, cellDims );
-				return new Cell<>(
-						cellDims,
-						cellMin,
-						cacheArrayLoader.loadArray( timepoint, setup, level, cellDims, cellMin ) );
-			}
+		final CacheLoader< Long, Cell< ? > > loader = key -> {
+			final int n = grid.numDimensions();
+			final long[] cellMin = new long[ n ];
+			final int[] cellDims = new int[ n ];
+			grid.getCellDimensions( key, cellMin, cellDims );
+			return new Cell<>(
+					cellDims,
+					cellMin,
+					cacheArrayLoader.loadArray( timepoint, setup, level, cellDims, cellMin ) );
+		};
+		return createImg( grid, timepoint, setup, level, cacheHints, loader, cacheArrayLoader.getEmptyArrayCreator(), type );
+	}
+
+	/**
+	 * Create a {@link VolatileCachedCellImg} backed by this {@link VolatileGlobalCellCache},
+	 * using the provided {@link SimpleCacheArrayLoader} to load data.
+	 *
+	 * @param grid
+	 * @param timepoint
+	 * @param setup
+	 * @param level
+	 * @param cacheHints
+	 * @param cacheArrayLoader
+	 * @param type
+	 * @return
+	 */
+	public < T extends NativeType< T >, A > VolatileCachedCellImg< T, A > createImg(
+			final CellGrid grid,
+			final int timepoint,
+			final int setup,
+			final int level,
+			final CacheHints cacheHints,
+			final SimpleCacheArrayLoader< A > cacheArrayLoader,
+			final T type )
+	{
+		final CacheLoader< Long, Cell< ? > > loader = key -> {
+			final int n = grid.numDimensions();
+			final long[] cellMin = new long[ n ];
+			final int[] cellDims = new int[ n ];
+			final long[] cellGridPosition = new long[ n ];
+			grid.getCellDimensions( key, cellMin, cellDims );
+			grid.getCellGridPositionFlat( key, cellGridPosition );
+			return new Cell<>( cellDims, cellMin, cacheArrayLoader.loadArray( cellGridPosition ) );
 		};
+		return createImg( grid, timepoint, setup, level, cacheHints, loader, cacheArrayLoader.getEmptyArrayCreator(), type );
+	}
 
+	private < T extends NativeType< T >, A > VolatileCachedCellImg< T, A > createImg(
+			final CellGrid grid,
+			final int timepoint,
+			final int setup,
+			final int level,
+			final CacheHints cacheHints,
+			final CacheLoader< Long, Cell< ? > > loader,
+			final EmptyArrayCreator< A > emptyArrayCreator, // optional, can be null
+			final T type )
+	{
 		final KeyBimap< Long, Key > bimap = KeyBimap.build(
 				index -> new Key( timepoint, setup, level, index ),
 				key -> ( key.timepoint == timepoint && key.setup == setup && key.level == level )
@@ -228,14 +268,13 @@ public class VolatileGlobalCellCache implements CacheControl
 				.mapKeys( bimap )
 				.withLoader( loader );
 
-		final EmptyArrayCreator< A > emptyArrayCreator = cacheArrayLoader.getEmptyArrayCreator();
 		final CreateInvalidVolatileCell< ? > createInvalid = ( emptyArrayCreator == null )
 				? CreateInvalidVolatileCell.get( grid, type, false )
 				: new CreateInvalidVolatileCell<>( grid, type.getEntitiesPerPixel(), emptyArrayCreator );
 
 		final UncheckedVolatileCache< Long, Cell< ? > > vcache = new WeakRefVolatileCache<>(
 				cache, queue, createInvalid )
-						.unchecked();
+				.unchecked();
 
 		@SuppressWarnings( "unchecked" )
 		final VolatileCachedCellImg< T, A > img = new VolatileCachedCellImg<>( grid, type, cacheHints,
diff --git a/src/main/java/bdv/img/catmaid/CatmaidImageLoader.java b/src/main/java/bdv/img/catmaid/CatmaidImageLoader.java
index bae9af7f9b6e9e5f4fe858b914cc2ac36b185b72..532484294b62ad13342aa4c2b066947f2c17322d 100644
--- a/src/main/java/bdv/img/catmaid/CatmaidImageLoader.java
+++ b/src/main/java/bdv/img/catmaid/CatmaidImageLoader.java
@@ -1,19 +1,18 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
- *
+ * 
  * 1. Redistributions of source code must retain the above copyright notice,
  *    this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
  *    this list of conditions and the following disclaimer in the documentation
  *    and/or other materials provided with the distribution.
- *
+ * 
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
diff --git a/src/main/java/bdv/img/catmaid/CatmaidVolatileIntArrayLoader.java b/src/main/java/bdv/img/catmaid/CatmaidVolatileIntArrayLoader.java
index da890bf370ec7bae1e3fa4e3e163a4f2d4c1695f..5d381bc2bada6674870c52fefff120efbae2cb9e 100644
--- a/src/main/java/bdv/img/catmaid/CatmaidVolatileIntArrayLoader.java
+++ b/src/main/java/bdv/img/catmaid/CatmaidVolatileIntArrayLoader.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/img/catmaid/XmlIoCatmaidImageLoader.java b/src/main/java/bdv/img/catmaid/XmlIoCatmaidImageLoader.java
index 22ec0231db4fe6c0ad49df01e17657e83c087846..bd5343fad8a8bda47516830824831b39d1169ecc 100644
--- a/src/main/java/bdv/img/catmaid/XmlIoCatmaidImageLoader.java
+++ b/src/main/java/bdv/img/catmaid/XmlIoCatmaidImageLoader.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/img/hdf5/DimsAndExistence.java b/src/main/java/bdv/img/hdf5/DimsAndExistence.java
index f84e74c64ae92c113a8ec8f42b6d78cfa76a8c79..7939223367408e04aef0192e0794deb4c6a64240 100644
--- a/src/main/java/bdv/img/hdf5/DimsAndExistence.java
+++ b/src/main/java/bdv/img/hdf5/DimsAndExistence.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -33,7 +32,7 @@ package bdv.img.hdf5;
  * The dimensions of an image and a flag indicating whether that image
  * exists (can be loaded)
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
 public class DimsAndExistence
 {
diff --git a/src/main/java/bdv/img/hdf5/HDF5Access.java b/src/main/java/bdv/img/hdf5/HDF5Access.java
index b3344a0e75257f9542aa541792fc3675bf715ec4..d379a9be1c3d4ed34fcfaa43ed7d43a92abe593a 100644
--- a/src/main/java/bdv/img/hdf5/HDF5Access.java
+++ b/src/main/java/bdv/img/hdf5/HDF5Access.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/img/hdf5/HDF5AccessHack.java b/src/main/java/bdv/img/hdf5/HDF5AccessHack.java
index 427b9f61235596e4545d6120133b9019da3bb020..8d437950cbd9b325fa326e6dceea85cdab4d2994 100644
--- a/src/main/java/bdv/img/hdf5/HDF5AccessHack.java
+++ b/src/main/java/bdv/img/hdf5/HDF5AccessHack.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -58,7 +57,7 @@ import ch.systemsx.cisd.hdf5.IHDF5Reader;
  * The HDF5 fileId is extracted from a jhdf5 HDF5Reader using reflection to
  * avoid having to do everything ourselves.
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
 class HDF5AccessHack implements IHDF5Access
 {
diff --git a/src/main/java/bdv/img/hdf5/Hdf5ImageLoader.java b/src/main/java/bdv/img/hdf5/Hdf5ImageLoader.java
index 4c54c99ca89384b2669a9f5901f88bd42ee1b406..c376732f3be6fc076aa08b92c00d86e2a7c4fa36 100644
--- a/src/main/java/bdv/img/hdf5/Hdf5ImageLoader.java
+++ b/src/main/java/bdv/img/hdf5/Hdf5ImageLoader.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -38,8 +37,6 @@ import java.util.Arrays;
 import java.util.HashMap;
 import java.util.List;
 import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
 
 import bdv.AbstractViewerSetupImgLoader;
 import bdv.ViewerImgLoader;
@@ -63,17 +60,13 @@ import net.imglib2.Cursor;
 import net.imglib2.Dimensions;
 import net.imglib2.FinalDimensions;
 import net.imglib2.FinalInterval;
-import net.imglib2.IterableInterval;
-import net.imglib2.RandomAccess;
 import net.imglib2.RandomAccessibleInterval;
 import net.imglib2.cache.queue.BlockingFetchQueues;
 import net.imglib2.cache.queue.FetcherThreads;
 import net.imglib2.cache.volatiles.CacheHints;
 import net.imglib2.cache.volatiles.LoadingStrategy;
 import net.imglib2.img.Img;
-import net.imglib2.img.ImgFactory;
 import net.imglib2.img.NativeImg;
-import net.imglib2.img.array.ArrayImgFactory;
 import net.imglib2.img.array.ArrayImgs;
 import net.imglib2.img.basictypeaccess.array.ShortArray;
 import net.imglib2.img.cell.Cell;
@@ -83,7 +76,6 @@ import net.imglib2.img.cell.CellImgFactory;
 import net.imglib2.realtransform.AffineTransform3D;
 import net.imglib2.type.NativeType;
 import net.imglib2.type.numeric.integer.UnsignedShortType;
-import net.imglib2.type.numeric.real.FloatType;
 import net.imglib2.type.volatiles.VolatileUnsignedShortType;
 import net.imglib2.util.Intervals;
 import net.imglib2.view.Views;
@@ -363,27 +355,6 @@ public class Hdf5ImageLoader implements ViewerImgLoader, MultiResolutionImgLoade
 		}
 	}
 
-	/**
-	 * normalize img to 0...1
-	 */
-	protected static void normalize( final IterableInterval< FloatType > img )
-	{
-		float currentMax = img.firstElement().get();
-		float currentMin = currentMax;
-		for ( final FloatType t : img )
-		{
-			final float f = t.get();
-			if ( f > currentMax )
-				currentMax = f;
-			else if ( f < currentMin )
-				currentMin = f;
-		}
-
-		final float scale = ( float ) ( 1.0 / ( currentMax - currentMin ) );
-		for ( final FloatType t : img )
-			t.set( ( t.get() - currentMin ) * scale );
-	}
-
 	@Override
 	public SetupImgLoader getSetupImgLoader( final int setupId )
 	{
@@ -516,9 +487,7 @@ public class Hdf5ImageLoader implements ViewerImgLoader, MultiResolutionImgLoade
 		}
 
 		/**
-		 * (Almost) create a {@link CellImg} backed by the cache.
-		 * The created image needs a {@link NativeImg#setLinkedType(net.imglib2.type.Type) linked type} before it can be used.
-		 * The type should be either {@link UnsignedShortType} and {@link VolatileUnsignedShortType}.
+		 * Create a {@link CellImg} backed by the cache.
 		 */
 		protected < T extends NativeType< T > > RandomAccessibleInterval< T > prepareCachedImage( final int timepointId, final int level, final LoadingStrategy loadingStrategy, final T type )
 		{
@@ -555,118 +524,6 @@ public class Hdf5ImageLoader implements ViewerImgLoader, MultiResolutionImgLoade
 			return Views.interval( new ConstantRandomAccessible<>( constant, 3 ), new FinalInterval( d ) );
 		}
 
-		@Override
-		public RandomAccessibleInterval< FloatType > getFloatImage( final int timepointId, final boolean normalize, final ImgLoaderHint... hints )
-		{
-			return getFloatImage( timepointId, 0, normalize, hints );
-		}
-
-		@Override
-		public RandomAccessibleInterval< FloatType > getFloatImage( final int timepointId, final int level, final boolean normalize, final ImgLoaderHint... hints )
-		{
-			final RandomAccessibleInterval< UnsignedShortType > ushortImg = getImage( timepointId, level, hints );
-
-			// copy unsigned short img to float img
-
-			// create float img
-			final FloatType f = new FloatType();
-			final ImgFactory< FloatType > imgFactory;
-			if ( Intervals.numElements( ushortImg ) <= Integer.MAX_VALUE )
-			{
-				imgFactory = new ArrayImgFactory<>( f );
-			}
-			else
-			{
-				final long[] dimsLong = new long[ ushortImg.numDimensions() ];
-				ushortImg.dimensions( dimsLong );
-				final int[] cellDimensions = computeCellDimensions(
-						dimsLong,
-						mipmapInfo.getSubdivisions()[ level ] );
-				imgFactory = new CellImgFactory<>( f, cellDimensions );
-			}
-			final Img< FloatType > floatImg = imgFactory.create( ushortImg );
-
-			// set up executor service
-			final int numProcessors = Runtime.getRuntime().availableProcessors();
-			final ExecutorService taskExecutor = Executors.newFixedThreadPool( numProcessors );
-			final ArrayList< Callable< Void > > tasks = new ArrayList<>();
-
-			// set up all tasks
-			final int numPortions = numProcessors * 2;
-			final long threadChunkSize = floatImg.size() / numPortions;
-			final long threadChunkMod = floatImg.size() % numPortions;
-
-			for ( int portionID = 0; portionID < numPortions; ++portionID )
-			{
-				// move to the starting position of the current thread
-				final long startPosition = portionID * threadChunkSize;
-
-				// the last thread may has to run longer if the number of pixels cannot be divided by the number of threads
-				final long loopSize = ( portionID == numPortions - 1 ) ? threadChunkSize + threadChunkMod : threadChunkSize;
-
-				if ( Views.iterable( ushortImg ).iterationOrder().equals( floatImg.iterationOrder() ) )
-				{
-					tasks.add( new Callable< Void >()
-					{
-						@Override
-						public Void call() throws Exception
-						{
-							final Cursor< UnsignedShortType > in = Views.iterable( ushortImg ).cursor();
-							final Cursor< FloatType > out = floatImg.cursor();
-
-							in.jumpFwd( startPosition );
-							out.jumpFwd( startPosition );
-
-							for ( long j = 0; j < loopSize; ++j )
-								out.next().set( in.next().getRealFloat() );
-
-							return null;
-						}
-					} );
-				}
-				else
-				{
-					tasks.add( new Callable< Void >()
-					{
-						@Override
-						public Void call() throws Exception
-						{
-							final Cursor< UnsignedShortType > in = Views.iterable( ushortImg ).localizingCursor();
-							final RandomAccess< FloatType > out = floatImg.randomAccess();
-
-							in.jumpFwd( startPosition );
-
-							for ( long j = 0; j < loopSize; ++j )
-							{
-								final UnsignedShortType vin = in.next();
-								out.setPosition( in );
-								out.get().set( vin.getRealFloat() );
-							}
-
-							return null;
-						}
-					} );
-				}
-			}
-
-			try
-			{
-				// invokeAll() returns when all tasks are complete
-				taskExecutor.invokeAll( tasks );
-				taskExecutor.shutdown();
-			}
-			catch ( final InterruptedException e )
-			{
-				return null;
-			}
-
-			if ( normalize )
-				// normalize the image to 0...1
-				normalize( floatImg );
-
-			return floatImg;
-		}
-
 		public MipmapInfo getMipmapInfo()
 		{
 			return mipmapInfo;
@@ -690,12 +547,6 @@ public class Hdf5ImageLoader implements ViewerImgLoader, MultiResolutionImgLoade
 			return mipmapInfo.getNumLevels();
 		}
 
-		@Override
-		public Dimensions getImageSize( final int timepointId )
-		{
-			return getImageSize( timepointId, 0 );
-		}
-
 		@Override
 		public Dimensions getImageSize( final int timepointId, final int level )
 		{
diff --git a/src/main/java/bdv/img/hdf5/Hdf5VolatileShortArrayLoader.java b/src/main/java/bdv/img/hdf5/Hdf5VolatileShortArrayLoader.java
index 41c47dfb1b873d3f945a63e575d236568a4d2ad3..c1c56aa33ef830ef2a91b905882bebe74b95184f 100644
--- a/src/main/java/bdv/img/hdf5/Hdf5VolatileShortArrayLoader.java
+++ b/src/main/java/bdv/img/hdf5/Hdf5VolatileShortArrayLoader.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/img/hdf5/IHDF5Access.java b/src/main/java/bdv/img/hdf5/IHDF5Access.java
index 1c5a5d2a1afdc998cfe22512f44d3a1314e17324..f0f07a7fb415611cfa21df5ef989306afb384852 100644
--- a/src/main/java/bdv/img/hdf5/IHDF5Access.java
+++ b/src/main/java/bdv/img/hdf5/IHDF5Access.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -31,17 +30,17 @@ package bdv.img.hdf5;
 
 interface IHDF5Access
 {
-	public DimsAndExistence getDimsAndExistence( final ViewLevelId id );
+	DimsAndExistence getDimsAndExistence( final ViewLevelId id );
 
-	public short[] readShortMDArrayBlockWithOffset( final int timepoint, final int setup, final int level, final int[] dimensions, final long[] min ) throws InterruptedException;
+	short[] readShortMDArrayBlockWithOffset( final int timepoint, final int setup, final int level, final int[] dimensions, final long[] min ) throws InterruptedException;
 
-	public short[] readShortMDArrayBlockWithOffset( final int timepoint, final int setup, final int level, final int[] dimensions, final long[] min, final short[] dataBlock ) throws InterruptedException;
+	short[] readShortMDArrayBlockWithOffset( final int timepoint, final int setup, final int level, final int[] dimensions, final long[] min, final short[] dataBlock ) throws InterruptedException;
 
-	public float[] readShortMDArrayBlockWithOffsetAsFloat( final int timepoint, final int setup, final int level, final int[] dimensions, final long[] min ) throws InterruptedException;
+	float[] readShortMDArrayBlockWithOffsetAsFloat( final int timepoint, final int setup, final int level, final int[] dimensions, final long[] min ) throws InterruptedException;
 
-	public float[] readShortMDArrayBlockWithOffsetAsFloat( final int timepoint, final int setup, final int level, final int[] dimensions, final long[] min, final float[] dataBlock ) throws InterruptedException;
+	float[] readShortMDArrayBlockWithOffsetAsFloat( final int timepoint, final int setup, final int level, final int[] dimensions, final long[] min, final float[] dataBlock ) throws InterruptedException;
 
-	public void closeAllDataSets();
+	void closeAllDataSets();
 
-	public void close();
+	void close();
 }
diff --git a/src/main/java/bdv/img/hdf5/MipmapInfo.java b/src/main/java/bdv/img/hdf5/MipmapInfo.java
index 23816589bdff639d4066971618f192a69fc801cd..baef04981b17368cb303a20365c171e923eefe6f 100644
--- a/src/main/java/bdv/img/hdf5/MipmapInfo.java
+++ b/src/main/java/bdv/img/hdf5/MipmapInfo.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -37,8 +36,9 @@ import net.imglib2.realtransform.AffineTransform3D;
  * Contains for each mipmap level, the subsampling factors and subdivision
  * block sizes.
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
+// TODO Move to bdv.img package
 public class MipmapInfo
 {
 	/**
diff --git a/src/main/java/bdv/img/hdf5/Partition.java b/src/main/java/bdv/img/hdf5/Partition.java
index f46a2f754f3e9318c599f114bf346d7801255db9..2c7e0716d5e849245b5d5ea2ef9ef7e4e9e461fb 100644
--- a/src/main/java/bdv/img/hdf5/Partition.java
+++ b/src/main/java/bdv/img/hdf5/Partition.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -45,7 +44,7 @@ import mpicbg.spim.data.sequence.ViewId;
  * created using the Partition information (without looking at the constituent
  * files).
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
 public class Partition
 {
diff --git a/src/main/java/bdv/img/hdf5/Util.java b/src/main/java/bdv/img/hdf5/Util.java
index 7da106bc3d5758f2563047e22cfb87cc43632e53..663d09e182201d97d282be06819a667b6f61659c 100644
--- a/src/main/java/bdv/img/hdf5/Util.java
+++ b/src/main/java/bdv/img/hdf5/Util.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/img/hdf5/ViewLevelId.java b/src/main/java/bdv/img/hdf5/ViewLevelId.java
index 98a8e1d3dd6fd75c6d6418ee5eac25a939942329..ace8491d2d40e351572019777281bb55869d681a 100644
--- a/src/main/java/bdv/img/hdf5/ViewLevelId.java
+++ b/src/main/java/bdv/img/hdf5/ViewLevelId.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/img/hdf5/XmlIoHdf5ImageLoader.java b/src/main/java/bdv/img/hdf5/XmlIoHdf5ImageLoader.java
index 720f150338023cf5c1fc280428c846c465f189be..e211e5986258faaa60dd6fce4efa96e6c8245111 100644
--- a/src/main/java/bdv/img/hdf5/XmlIoHdf5ImageLoader.java
+++ b/src/main/java/bdv/img/hdf5/XmlIoHdf5ImageLoader.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/img/imaris/DataTypes.java b/src/main/java/bdv/img/imaris/DataTypes.java
index 239cee7d37e000efed3efd8dd1d99ad95a77a9d1..3f35908ba229a4e328d27aff829dec0f9c10e7b4 100644
--- a/src/main/java/bdv/img/imaris/DataTypes.java
+++ b/src/main/java/bdv/img/imaris/DataTypes.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -45,16 +44,16 @@ import net.imglib2.type.volatiles.VolatileUnsignedShortType;
 
 class DataTypes
 {
-	static interface DataType<
+	interface DataType<
 			T extends NativeType< T >,
 			V extends Volatile< T > & NativeType< V > ,
 			A extends VolatileAccess >
 	{
-		public T getType();
+		T getType();
 
-		public V getVolatileType();
+		V getVolatileType();
 
-		public CacheArrayLoader< A > createArrayLoader( final IHDF5Access hdf5Access );
+		CacheArrayLoader< A > createArrayLoader( final IHDF5Access hdf5Access );
 	}
 
 	static DataType< UnsignedByteType, VolatileUnsignedByteType, VolatileByteArray > UnsignedByte =
diff --git a/src/main/java/bdv/img/imaris/HDF5AccessHack.java b/src/main/java/bdv/img/imaris/HDF5AccessHack.java
index 22e54dc8042c010e0785cd16189cfc165954b070..bd855276f087e87389f046da494103ad11b3f883 100644
--- a/src/main/java/bdv/img/imaris/HDF5AccessHack.java
+++ b/src/main/java/bdv/img/imaris/HDF5AccessHack.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/img/imaris/IHDF5Access.java b/src/main/java/bdv/img/imaris/IHDF5Access.java
index 9f8f994595feea9e67ddfab431e30da641f42cc7..b58f34f54cad04397499d4c3906840d85e993279 100644
--- a/src/main/java/bdv/img/imaris/IHDF5Access.java
+++ b/src/main/java/bdv/img/imaris/IHDF5Access.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -34,21 +33,21 @@ import bdv.img.hdf5.ViewLevelId;
 
 public interface IHDF5Access
 {
-	public DimsAndExistence getDimsAndExistence( final ViewLevelId id );
+	DimsAndExistence getDimsAndExistence( final ViewLevelId id );
 
-	public byte[] readByteMDArrayBlockWithOffset( final int timepoint, final int setup, final int level, final int[] dimensions, final long[] min ) throws InterruptedException;
+	byte[] readByteMDArrayBlockWithOffset( final int timepoint, final int setup, final int level, final int[] dimensions, final long[] min ) throws InterruptedException;
 
-	public byte[] readByteMDArrayBlockWithOffset( final int timepoint, final int setup, final int level, final int[] dimensions, final long[] min, final byte[] dataBlock ) throws InterruptedException;
+	byte[] readByteMDArrayBlockWithOffset( final int timepoint, final int setup, final int level, final int[] dimensions, final long[] min, final byte[] dataBlock ) throws InterruptedException;
 
-	public short[] readShortMDArrayBlockWithOffset( final int timepoint, final int setup, final int level, final int[] dimensions, final long[] min ) throws InterruptedException;
+	short[] readShortMDArrayBlockWithOffset( final int timepoint, final int setup, final int level, final int[] dimensions, final long[] min ) throws InterruptedException;
 
-	public short[] readShortMDArrayBlockWithOffset( final int timepoint, final int setup, final int level, final int[] dimensions, final long[] min, final short[] dataBlock ) throws InterruptedException;
+	short[] readShortMDArrayBlockWithOffset( final int timepoint, final int setup, final int level, final int[] dimensions, final long[] min, final short[] dataBlock ) throws InterruptedException;
 
-	public float[] readFloatMDArrayBlockWithOffset( final int timepoint, final int setup, final int level, final int[] dimensions, final long[] min ) throws InterruptedException;
+	float[] readFloatMDArrayBlockWithOffset( final int timepoint, final int setup, final int level, final int[] dimensions, final long[] min ) throws InterruptedException;
 
-	public float[] readFloatMDArrayBlockWithOffset( final int timepoint, final int setup, final int level, final int[] dimensions, final long[] min, final float[] dataBlock ) throws InterruptedException;
+	float[] readFloatMDArrayBlockWithOffset( final int timepoint, final int setup, final int level, final int[] dimensions, final long[] min, final float[] dataBlock ) throws InterruptedException;
 
-	public String readImarisAttributeString( final String objectPath, final String attributeName );
+	String readImarisAttributeString( final String objectPath, final String attributeName );
 
-	public String readImarisAttributeString( final String objectPath, final String attributeName, final String defaultValue );
+	String readImarisAttributeString( final String objectPath, final String attributeName, final String defaultValue );
 }
diff --git a/src/main/java/bdv/img/imaris/Imaris.java b/src/main/java/bdv/img/imaris/Imaris.java
index d2187532e632f4c398f17ed3eab61168d6af456b..7e3ce052bcc233f841571eae366f11f0ec6c925c 100644
--- a/src/main/java/bdv/img/imaris/Imaris.java
+++ b/src/main/java/bdv/img/imaris/Imaris.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/img/imaris/ImarisImageLoader.java b/src/main/java/bdv/img/imaris/ImarisImageLoader.java
index 5a3c425f4c90f0eb3ee26d651aa12e87b54a4ec3..6fa4509ca96cc4af95c84c31b94d590bfff2762e 100644
--- a/src/main/java/bdv/img/imaris/ImarisImageLoader.java
+++ b/src/main/java/bdv/img/imaris/ImarisImageLoader.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/img/imaris/ImarisVolatileByteArrayLoader.java b/src/main/java/bdv/img/imaris/ImarisVolatileByteArrayLoader.java
index a1f5bdf9d615a73ae85b7cc4c3e3e829f35c51cd..e58f9fa3958648f6481a56fa490156e448ff2066 100644
--- a/src/main/java/bdv/img/imaris/ImarisVolatileByteArrayLoader.java
+++ b/src/main/java/bdv/img/imaris/ImarisVolatileByteArrayLoader.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/img/imaris/ImarisVolatileFloatArrayLoader.java b/src/main/java/bdv/img/imaris/ImarisVolatileFloatArrayLoader.java
index 69068c2ddeb3c177d4d7d6d9dda6bbb3a4fe44c2..11dc4b12ef95d3c6c8cf104a19b1f1a53d1a6b83 100644
--- a/src/main/java/bdv/img/imaris/ImarisVolatileFloatArrayLoader.java
+++ b/src/main/java/bdv/img/imaris/ImarisVolatileFloatArrayLoader.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/img/imaris/ImarisVolatileShortArrayLoader.java b/src/main/java/bdv/img/imaris/ImarisVolatileShortArrayLoader.java
index 77cec55f26ab21d69d1bd33f0f1b2c98de715095..0eecf007ab0d2faf8d0a5fc2a451b44e1eac308f 100644
--- a/src/main/java/bdv/img/imaris/ImarisVolatileShortArrayLoader.java
+++ b/src/main/java/bdv/img/imaris/ImarisVolatileShortArrayLoader.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/img/n5/BdvN5Format.java b/src/main/java/bdv/img/n5/BdvN5Format.java
new file mode 100644
index 0000000000000000000000000000000000000000..145f1ef1a60f78420e57dba032ba96cbd6809bba
--- /dev/null
+++ b/src/main/java/bdv/img/n5/BdvN5Format.java
@@ -0,0 +1,50 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.img.n5;
+
+public class BdvN5Format
+{
+	public static final String DOWNSAMPLING_FACTORS_KEY = "downsamplingFactors";
+	public static final String DATA_TYPE_KEY = "dataType";
+
+	public static String getPathName( final int setupId )
+	{
+		return String.format( "setup%d", setupId );
+	}
+
+	public static String getPathName( final int setupId, final int timepointId )
+	{
+		return String.format( "setup%d/timepoint%d", setupId, timepointId );
+	}
+
+	public static String getPathName( final int setupId, final int timepointId, final int level )
+	{
+		return String.format( "setup%d/timepoint%d/s%d", setupId, timepointId, level );
+	}
+}
diff --git a/src/main/java/bdv/img/n5/N5ImageLoader.java b/src/main/java/bdv/img/n5/N5ImageLoader.java
new file mode 100644
index 0000000000000000000000000000000000000000..e3d6db5a979ef47db9ae185ee31b338c9a2ee7cc
--- /dev/null
+++ b/src/main/java/bdv/img/n5/N5ImageLoader.java
@@ -0,0 +1,418 @@
+/*
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.img.n5;
+
+import bdv.AbstractViewerSetupImgLoader;
+import bdv.ViewerImgLoader;
+import bdv.cache.CacheControl;
+import bdv.img.cache.SimpleCacheArrayLoader;
+import bdv.img.cache.VolatileGlobalCellCache;
+import bdv.util.ConstantRandomAccessible;
+import bdv.util.MipmapTransforms;
+import java.io.File;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Callable;
+import java.util.function.Function;
+import mpicbg.spim.data.generic.sequence.AbstractSequenceDescription;
+import mpicbg.spim.data.generic.sequence.BasicViewSetup;
+import mpicbg.spim.data.generic.sequence.ImgLoaderHint;
+import mpicbg.spim.data.sequence.MultiResolutionImgLoader;
+import mpicbg.spim.data.sequence.MultiResolutionSetupImgLoader;
+import mpicbg.spim.data.sequence.VoxelDimensions;
+import net.imglib2.Dimensions;
+import net.imglib2.FinalDimensions;
+import net.imglib2.FinalInterval;
+import net.imglib2.RandomAccessibleInterval;
+import net.imglib2.Volatile;
+import net.imglib2.cache.queue.BlockingFetchQueues;
+import net.imglib2.cache.queue.FetcherThreads;
+import net.imglib2.cache.volatiles.CacheHints;
+import net.imglib2.cache.volatiles.LoadingStrategy;
+import net.imglib2.img.basictypeaccess.volatiles.array.VolatileByteArray;
+import net.imglib2.img.basictypeaccess.volatiles.array.VolatileDoubleArray;
+import net.imglib2.img.basictypeaccess.volatiles.array.VolatileFloatArray;
+import net.imglib2.img.basictypeaccess.volatiles.array.VolatileIntArray;
+import net.imglib2.img.basictypeaccess.volatiles.array.VolatileLongArray;
+import net.imglib2.img.basictypeaccess.volatiles.array.VolatileShortArray;
+import net.imglib2.img.cell.CellGrid;
+import net.imglib2.img.cell.CellImg;
+import net.imglib2.realtransform.AffineTransform3D;
+import net.imglib2.type.NativeType;
+import net.imglib2.type.numeric.integer.ByteType;
+import net.imglib2.type.numeric.integer.IntType;
+import net.imglib2.type.numeric.integer.LongType;
+import net.imglib2.type.numeric.integer.ShortType;
+import net.imglib2.type.numeric.integer.UnsignedByteType;
+import net.imglib2.type.numeric.integer.UnsignedIntType;
+import net.imglib2.type.numeric.integer.UnsignedLongType;
+import net.imglib2.type.numeric.integer.UnsignedShortType;
+import net.imglib2.type.numeric.real.DoubleType;
+import net.imglib2.type.numeric.real.FloatType;
+import net.imglib2.type.volatiles.VolatileByteType;
+import net.imglib2.type.volatiles.VolatileDoubleType;
+import net.imglib2.type.volatiles.VolatileFloatType;
+import net.imglib2.type.volatiles.VolatileIntType;
+import net.imglib2.type.volatiles.VolatileLongType;
+import net.imglib2.type.volatiles.VolatileShortType;
+import net.imglib2.type.volatiles.VolatileUnsignedByteType;
+import net.imglib2.type.volatiles.VolatileUnsignedIntType;
+import net.imglib2.type.volatiles.VolatileUnsignedLongType;
+import net.imglib2.type.volatiles.VolatileUnsignedShortType;
+import net.imglib2.util.Cast;
+import net.imglib2.view.Views;
+import org.janelia.saalfeldlab.n5.*;
+
+import static bdv.img.n5.BdvN5Format.DATA_TYPE_KEY;
+import static bdv.img.n5.BdvN5Format.DOWNSAMPLING_FACTORS_KEY;
+import static bdv.img.n5.BdvN5Format.getPathName;
+
+public class N5ImageLoader implements ViewerImgLoader, MultiResolutionImgLoader
+{
+	private final File n5File;
+
+	// TODO: it would be good if this would not be needed
+	//       find available setups from the n5
+	private final AbstractSequenceDescription< ?, ?, ? > seq;
+
+	/**
+	 * Maps setup id to {@link SetupImgLoader}.
+	 */
+	private final Map< Integer, SetupImgLoader > setupImgLoaders = new HashMap<>();
+
+	public N5ImageLoader( final File n5File, final AbstractSequenceDescription< ?, ?, ? > sequenceDescription )
+	{
+		this.n5File = n5File;
+		this.seq = sequenceDescription;
+	}
+
+	public File getN5File()
+	{
+		return n5File;
+	}
+
+	private volatile boolean isOpen = false;
+	private FetcherThreads fetchers;
+	private VolatileGlobalCellCache cache;
+	private N5Reader n5;
+
+	private void open()
+	{
+		if ( !isOpen )
+		{
+			synchronized ( this )
+			{
+				if ( isOpen )
+					return;
+
+				try
+				{
+					this.n5 = new N5FSReader( n5File.getAbsolutePath() );
+
+					int maxNumLevels = 0;
+					final List< ? extends BasicViewSetup > setups = seq.getViewSetupsOrdered();
+					for ( final BasicViewSetup setup : setups )
+					{
+						final int setupId = setup.getId();
+						final SetupImgLoader setupImgLoader = createSetupImgLoader( setupId );
+						setupImgLoaders.put( setupId, setupImgLoader );
+						maxNumLevels = Math.max( maxNumLevels, setupImgLoader.numMipmapLevels() );
+					}
+
+					final int numFetcherThreads = Math.max( 1, Runtime.getRuntime().availableProcessors() );
+					final BlockingFetchQueues< Callable< ? > > queue = new BlockingFetchQueues<>( maxNumLevels, numFetcherThreads );
+					fetchers = new FetcherThreads( queue, numFetcherThreads );
+					cache = new VolatileGlobalCellCache( queue );
+				}
+				catch ( IOException e )
+				{
+					throw new RuntimeException( e );
+				}
+
+				isOpen = true;
+			}
+		}
+	}
+
+	/**
+	 * Clear the cache. Images that were obtained from
+	 * this loader before {@link #close()} will stop working. Requesting images
+	 * after {@link #close()} will cause the n5 to be reopened (with a
+	 * new cache).
+	 */
+	public void close()
+	{
+		if ( isOpen )
+		{
+			synchronized ( this )
+			{
+				if ( !isOpen )
+					return;
+				fetchers.shutdown();
+				cache.clearCache();
+				isOpen = false;
+			}
+		}
+	}
+
+	@Override
+	public SetupImgLoader getSetupImgLoader( final int setupId )
+	{
+		open();
+		return setupImgLoaders.get( setupId );
+	}
+
+	private < T extends NativeType< T >, V extends Volatile< T > & NativeType< V > > SetupImgLoader< T, V > createSetupImgLoader( final int setupId ) throws IOException
+	{
+		final String pathName = getPathName( setupId );
+		final DataType dataType = n5.getAttribute( pathName, DATA_TYPE_KEY, DataType.class );
+		switch ( dataType )
+		{
+		case UINT8:
+			return Cast.unchecked( new SetupImgLoader<>( setupId, new UnsignedByteType(), new VolatileUnsignedByteType() ) );
+		case UINT16:
+			return Cast.unchecked( new SetupImgLoader<>( setupId, new UnsignedShortType(), new VolatileUnsignedShortType() ) );
+		case UINT32:
+			return Cast.unchecked( new SetupImgLoader<>( setupId, new UnsignedIntType(), new VolatileUnsignedIntType() ) );
+		case UINT64:
+			return Cast.unchecked( new SetupImgLoader<>( setupId, new UnsignedLongType(), new VolatileUnsignedLongType() ) );
+		case INT8:
+			return Cast.unchecked( new SetupImgLoader<>( setupId, new ByteType(), new VolatileByteType() ) );
+		case INT16:
+			return Cast.unchecked( new SetupImgLoader<>( setupId, new ShortType(), new VolatileShortType() ) );
+		case INT32:
+			return Cast.unchecked( new SetupImgLoader<>( setupId, new IntType(), new VolatileIntType() ) );
+		case INT64:
+			return Cast.unchecked( new SetupImgLoader<>( setupId, new LongType(), new VolatileLongType() ) );
+		case FLOAT32:
+			return Cast.unchecked( new SetupImgLoader<>( setupId, new FloatType(), new VolatileFloatType() ) );
+		case FLOAT64:
+			return Cast.unchecked( new SetupImgLoader<>( setupId, new DoubleType(), new VolatileDoubleType() ) );
+		}
+		return null;
+	}
+
+	@Override
+	public CacheControl getCacheControl()
+	{
+		open();
+		return cache;
+	}
+
+	public class SetupImgLoader< T extends NativeType< T >, V extends Volatile< T > & NativeType< V > >
+			extends AbstractViewerSetupImgLoader< T, V >
+			implements MultiResolutionSetupImgLoader< T >
+	{
+		private final int setupId;
+
+		private final double[][] mipmapResolutions;
+
+		private final AffineTransform3D[] mipmapTransforms;
+
+		public SetupImgLoader( final int setupId, final T type, final V volatileType ) throws IOException
+		{
+			super( type, volatileType );
+			this.setupId = setupId;
+			final String pathName = getPathName( setupId );
+			mipmapResolutions = n5.getAttribute( pathName, DOWNSAMPLING_FACTORS_KEY, double[][].class );
+			mipmapTransforms = new AffineTransform3D[ mipmapResolutions.length ];
+			for ( int level = 0; level < mipmapResolutions.length; level++ )
+				mipmapTransforms[ level ] = MipmapTransforms.getMipmapTransformDefault( mipmapResolutions[ level ] );
+		}
+
+		@Override
+		public RandomAccessibleInterval< V > getVolatileImage( final int timepointId, final int level, final ImgLoaderHint... hints )
+		{
+			return prepareCachedImage( timepointId, level, LoadingStrategy.BUDGETED, volatileType );
+		}
+
+		@Override
+		public RandomAccessibleInterval< T > getImage( final int timepointId, final int level, final ImgLoaderHint... hints )
+		{
+			return prepareCachedImage( timepointId, level, LoadingStrategy.BLOCKING, type );
+		}
+
+		@Override
+		public Dimensions getImageSize( final int timepointId, final int level )
+		{
+			try
+			{
+				final String pathName = getPathName( setupId, timepointId, level );
+				final DatasetAttributes attributes = n5.getDatasetAttributes( pathName );
+				return new FinalDimensions( attributes.getDimensions() );
+			}
+			catch( Exception e )
+			{
+				return null;
+			}
+		}
+
+		@Override
+		public double[][] getMipmapResolutions()
+		{
+			return mipmapResolutions;
+		}
+
+		@Override
+		public AffineTransform3D[] getMipmapTransforms()
+		{
+			return mipmapTransforms;
+		}
+
+		@Override
+		public int numMipmapLevels()
+		{
+			return mipmapResolutions.length;
+		}
+
+		@Override
+		public VoxelDimensions getVoxelSize( final int timepointId )
+		{
+			return null;
+		}
+
+		/**
+		 * Create a {@link CellImg} backed by the cache.
+		 */
+		private < T extends NativeType< T > > RandomAccessibleInterval< T > prepareCachedImage( final int timepointId, final int level, final LoadingStrategy loadingStrategy, final T type )
+		{
+			try
+			{
+				final String pathName = getPathName( setupId, timepointId, level );
+				final DatasetAttributes attributes = n5.getDatasetAttributes( pathName );
+				final long[] dimensions = attributes.getDimensions();
+				final int[] cellDimensions = attributes.getBlockSize();
+				final CellGrid grid = new CellGrid( dimensions, cellDimensions );
+
+				final int priority = numMipmapLevels() - 1 - level;
+				final CacheHints cacheHints = new CacheHints( loadingStrategy, priority, false );
+
+				final SimpleCacheArrayLoader< ? > loader = createCacheArrayLoader( n5, pathName );
+				return cache.createImg( grid, timepointId, setupId, level, cacheHints, loader, type );
+			}
+			catch ( IOException e )
+			{
+				System.err.println( String.format(
+						"image data for timepoint %d setup %d level %d could not be found.",
+						timepointId, setupId, level ) );
+				return Views.interval(
+						new ConstantRandomAccessible<>( type.createVariable(), 3 ),
+						new FinalInterval( 1, 1, 1 ) );
+			}
+		}
+	}
+
+	private static class N5CacheArrayLoader< A > implements SimpleCacheArrayLoader< A >
+	{
+		private final N5Reader n5;
+		private final String pathName;
+		private final DatasetAttributes attributes;
+		private final Function< DataBlock< ? >, A > createArray;
+
+		N5CacheArrayLoader( final N5Reader n5, final String pathName, final DatasetAttributes attributes, final Function< DataBlock< ? >, A > createArray )
+		{
+			this.n5 = n5;
+			this.pathName = pathName;
+			this.attributes = attributes;
+			this.createArray = createArray;
+		}
+
+		@Override
+		public A loadArray( final long[] gridPosition ) throws IOException
+		{
+			final DataBlock< ? > dataBlock = n5.readBlock( pathName, attributes, gridPosition );
+
+			if ( dataBlock == null )
+				return createEmptyArray( gridPosition );
+			else
+				return createArray.apply( dataBlock );
+		}
+
+		private A createEmptyArray( long[] gridPosition )
+		{
+			final int[] blockSize = attributes.getBlockSize();
+			final int n = blockSize[ 0 ] * blockSize[ 1 ] * blockSize[ 2 ];
+			switch ( attributes.getDataType() )
+			{
+				case UINT8:
+				case INT8:
+					return createArray.apply( new ByteArrayDataBlock( blockSize, gridPosition, new byte[ n ] ) );
+				case UINT16:
+				case INT16:
+					return createArray.apply( new ShortArrayDataBlock( blockSize, gridPosition, new short[ n ] ) );
+				case UINT32:
+				case INT32:
+					return createArray.apply( new IntArrayDataBlock( blockSize, gridPosition, new int[ n ] ) );
+				case UINT64:
+				case INT64:
+					return createArray.apply( new LongArrayDataBlock( blockSize, gridPosition, new long[ n ] ) );
+				case FLOAT32:
+					return createArray.apply( new FloatArrayDataBlock( blockSize, gridPosition, new float[ n ] ) );
+				case FLOAT64:
+					return createArray.apply( new DoubleArrayDataBlock( blockSize, gridPosition, new double[ n ] ) );
+				default:
+					throw new UnsupportedOperationException("Data type not supported: " + attributes.getDataType());
+			}
+		}
+	}
+
+	public static SimpleCacheArrayLoader< ? > createCacheArrayLoader( final N5Reader n5, final String pathName ) throws IOException
+	{
+		final DatasetAttributes attributes = n5.getDatasetAttributes( pathName );
+		switch ( attributes.getDataType() )
+		{
+		case UINT8:
+		case INT8:
+			return new N5CacheArrayLoader<>( n5, pathName, attributes,
+					dataBlock -> new VolatileByteArray( Cast.unchecked( dataBlock.getData() ), true ) );
+		case UINT16:
+		case INT16:
+			return new N5CacheArrayLoader<>( n5, pathName, attributes,
+					dataBlock -> new VolatileShortArray( Cast.unchecked( dataBlock.getData() ), true ) );
+		case UINT32:
+		case INT32:
+			return new N5CacheArrayLoader<>( n5, pathName, attributes,
+					dataBlock -> new VolatileIntArray( Cast.unchecked( dataBlock.getData() ), true ) );
+		case UINT64:
+		case INT64:
+			return new N5CacheArrayLoader<>( n5, pathName, attributes,
+					dataBlock -> new VolatileLongArray( Cast.unchecked( dataBlock.getData() ), true ) );
+		case FLOAT32:
+			return new N5CacheArrayLoader<>( n5, pathName, attributes,
+					dataBlock -> new VolatileFloatArray( Cast.unchecked( dataBlock.getData() ), true ) );
+		case FLOAT64:
+			return new N5CacheArrayLoader<>( n5, pathName, attributes,
+					dataBlock -> new VolatileDoubleArray( Cast.unchecked( dataBlock.getData() ), true ) );
+		default:
+			throw new IllegalArgumentException();
+		}
+	}
+}
diff --git a/src/main/java/bdv/img/n5/XmlIoN5ImageLoader.java b/src/main/java/bdv/img/n5/XmlIoN5ImageLoader.java
new file mode 100644
index 0000000000000000000000000000000000000000..aa95e6bef6be169fdec38f6d2bdac6588fff9e09
--- /dev/null
+++ b/src/main/java/bdv/img/n5/XmlIoN5ImageLoader.java
@@ -0,0 +1,61 @@
+/*
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.img.n5;
+
+import java.io.File;
+import mpicbg.spim.data.XmlHelpers;
+import mpicbg.spim.data.generic.sequence.AbstractSequenceDescription;
+import mpicbg.spim.data.generic.sequence.ImgLoaderIo;
+import mpicbg.spim.data.generic.sequence.XmlIoBasicImgLoader;
+import org.jdom2.Element;
+
+import static mpicbg.spim.data.XmlHelpers.loadPath;
+import static mpicbg.spim.data.XmlKeys.IMGLOADER_FORMAT_ATTRIBUTE_NAME;
+
+@ImgLoaderIo( format = "bdv.n5", type = N5ImageLoader.class )
+public class XmlIoN5ImageLoader implements XmlIoBasicImgLoader< N5ImageLoader >
+{
+	@Override
+	public Element toXml( final N5ImageLoader imgLoader, final File basePath )
+	{
+		final Element elem = new Element( "ImageLoader" );
+		elem.setAttribute( IMGLOADER_FORMAT_ATTRIBUTE_NAME, "bdv.n5" );
+		elem.setAttribute( "version", "1.0" );
+		elem.addContent( XmlHelpers.pathElement( "n5", imgLoader.getN5File(), basePath ) );
+		return elem;
+	}
+
+	@Override
+	public N5ImageLoader fromXml( final Element elem, final File basePath, final AbstractSequenceDescription< ?, ?, ? > sequenceDescription )
+	{
+//		final String version = elem.getAttributeValue( "version" );
+		final File path = loadPath( elem, "n5", basePath );
+		return new N5ImageLoader( path, sequenceDescription );
+	}
+}
diff --git a/src/main/java/bdv/img/openconnectome/OpenConnectomeDataset.java b/src/main/java/bdv/img/openconnectome/OpenConnectomeDataset.java
index dc51630af63c9ae74d3c3718738669f5433c8935..a4b7f95109b6e8187717d4ef4772ca6c71fbd327 100644
--- a/src/main/java/bdv/img/openconnectome/OpenConnectomeDataset.java
+++ b/src/main/java/bdv/img/openconnectome/OpenConnectomeDataset.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -38,7 +37,7 @@ import java.util.HashMap;
  *
  * <a href="http://openconnecto.me/ocp/ca/bock11/info/">http://openconnecto.me/ocp/ca/bock11/info/</a>
  *
- * @author Stephan Saalfeld &lt;saalfelds@janelia.hhmi.org&gt;
+ * @author Stephan Saalfeld
  */
 public class OpenConnectomeDataset implements Serializable
 {
diff --git a/src/main/java/bdv/img/openconnectome/OpenConnectomeImageLoader.java b/src/main/java/bdv/img/openconnectome/OpenConnectomeImageLoader.java
index 263c83fcccced7347a83aded2e58275d1355813b..f4449a0e144c7325d675a3965a8b85a9a2cb33b4 100644
--- a/src/main/java/bdv/img/openconnectome/OpenConnectomeImageLoader.java
+++ b/src/main/java/bdv/img/openconnectome/OpenConnectomeImageLoader.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/img/openconnectome/OpenConnectomeProject.java b/src/main/java/bdv/img/openconnectome/OpenConnectomeProject.java
index d462d926df85024495c2e87302c6256448adba40..ab77eae8e9cccf428969e1d33db7f423c02449dc 100644
--- a/src/main/java/bdv/img/openconnectome/OpenConnectomeProject.java
+++ b/src/main/java/bdv/img/openconnectome/OpenConnectomeProject.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -36,7 +35,7 @@ import java.io.Serializable;
  *
  * {@linkplain }
  *
- * @author Stephan Saalfeld &lt;saalfelds@janelia.hhmi.org&gt;
+ * @author Stephan Saalfeld
  */
 public class OpenConnectomeProject implements Serializable
 {
diff --git a/src/main/java/bdv/img/openconnectome/OpenConnectomeTokenInfo.java b/src/main/java/bdv/img/openconnectome/OpenConnectomeTokenInfo.java
index 7b6cad957f2540b1158de2a5a3a7a1b65af77de1..876c92ea111a9a7d38ff3235ed4f2fe374925b29 100644
--- a/src/main/java/bdv/img/openconnectome/OpenConnectomeTokenInfo.java
+++ b/src/main/java/bdv/img/openconnectome/OpenConnectomeTokenInfo.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/img/openconnectome/OpenConnectomeVolatileArrayLoader.java b/src/main/java/bdv/img/openconnectome/OpenConnectomeVolatileArrayLoader.java
index 1d9214fe4244fa43a9ad982e2da533045c99c927..47ea4879e67e2198c6be52dfb263bdb30d166d33 100644
--- a/src/main/java/bdv/img/openconnectome/OpenConnectomeVolatileArrayLoader.java
+++ b/src/main/java/bdv/img/openconnectome/OpenConnectomeVolatileArrayLoader.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/img/openconnectome/XmlIoOpenConnectomeImageLoader.java b/src/main/java/bdv/img/openconnectome/XmlIoOpenConnectomeImageLoader.java
index b04e312e20c56ccd9663053b0e80e345cff3b4a3..8f4ed67656fb56cf8102fbe82aa01c344ae5b0f9 100644
--- a/src/main/java/bdv/img/openconnectome/XmlIoOpenConnectomeImageLoader.java
+++ b/src/main/java/bdv/img/openconnectome/XmlIoOpenConnectomeImageLoader.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/img/remote/AffineTransform3DJsonSerializer.java b/src/main/java/bdv/img/remote/AffineTransform3DJsonSerializer.java
index 452e88cb83c8ed989c47300b14116b41439d7686..0b0f0dcdc2c6ad7edb3fd90ea00ca1e560e2521a 100644
--- a/src/main/java/bdv/img/remote/AffineTransform3DJsonSerializer.java
+++ b/src/main/java/bdv/img/remote/AffineTransform3DJsonSerializer.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/img/remote/RemoteImageLoader.java b/src/main/java/bdv/img/remote/RemoteImageLoader.java
index 949c7cf330d729a023a3ed5d57a3e0b7a37d84e8..01835d7790c86af1b55466e22a035fd41c4964cc 100644
--- a/src/main/java/bdv/img/remote/RemoteImageLoader.java
+++ b/src/main/java/bdv/img/remote/RemoteImageLoader.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/img/remote/RemoteImageLoaderMetaData.java b/src/main/java/bdv/img/remote/RemoteImageLoaderMetaData.java
index cc0f1e38219b2f3c8d2e8bf5e729960dc42e4e08..b08fe14d48cc1dfbab91dc8681176cc0724935d8 100644
--- a/src/main/java/bdv/img/remote/RemoteImageLoaderMetaData.java
+++ b/src/main/java/bdv/img/remote/RemoteImageLoaderMetaData.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/img/remote/RemoteVolatileShortArrayLoader.java b/src/main/java/bdv/img/remote/RemoteVolatileShortArrayLoader.java
index 7a85c86b23b11a1237f711982f5f250d28b757ba..8d6dbb9edc3455974f7719ef15e3d1ce5c5dd81c 100644
--- a/src/main/java/bdv/img/remote/RemoteVolatileShortArrayLoader.java
+++ b/src/main/java/bdv/img/remote/RemoteVolatileShortArrayLoader.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/img/remote/XmlIoRemoteImageLoader.java b/src/main/java/bdv/img/remote/XmlIoRemoteImageLoader.java
index 6b9b8a03b3687b30b4f0345429dfe455298770e0..fd53dc5a2cea9b3b0afa2fbb09d3b323cc2b1979 100644
--- a/src/main/java/bdv/img/remote/XmlIoRemoteImageLoader.java
+++ b/src/main/java/bdv/img/remote/XmlIoRemoteImageLoader.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/spimdata/SequenceDescriptionMinimal.java b/src/main/java/bdv/spimdata/SequenceDescriptionMinimal.java
index de5c21db173f8ee35b19169a2c38b112bcb528d0..d75c37bd3c8d5960707bda70973daf611b3aedb7 100644
--- a/src/main/java/bdv/spimdata/SequenceDescriptionMinimal.java
+++ b/src/main/java/bdv/spimdata/SequenceDescriptionMinimal.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/spimdata/SpimDataMinimal.java b/src/main/java/bdv/spimdata/SpimDataMinimal.java
index bbd41f29ce0053e1e8debc9ef791d503dc5a3eb3..1c2829c2464750b17a9409d75b2823b941e2887f 100644
--- a/src/main/java/bdv/spimdata/SpimDataMinimal.java
+++ b/src/main/java/bdv/spimdata/SpimDataMinimal.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/spimdata/WrapBasicImgLoader.java b/src/main/java/bdv/spimdata/WrapBasicImgLoader.java
index 6b7802192e1c50414903b52ff441354dbfd88abf..522bc8ce1b45cc8aff2bdf378e163eb17113d91a 100644
--- a/src/main/java/bdv/spimdata/WrapBasicImgLoader.java
+++ b/src/main/java/bdv/spimdata/WrapBasicImgLoader.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/spimdata/XmlIoSpimDataMinimal.java b/src/main/java/bdv/spimdata/XmlIoSpimDataMinimal.java
index 2988d6d25513031886122e82e3ee78b85b9780ac..2d5daf3cb1bde2824b930c8d4f23f52438f5b5d9 100644
--- a/src/main/java/bdv/spimdata/XmlIoSpimDataMinimal.java
+++ b/src/main/java/bdv/spimdata/XmlIoSpimDataMinimal.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/spimdata/legacy/AbstractLegacyViewerImgLoader.java b/src/main/java/bdv/spimdata/legacy/AbstractLegacyViewerImgLoader.java
index b529b98f6d2fb2ceb0aa8358e52bb979d01e01f2..2c6520dc00f103789eb59d0c74e426d0e571a2e1 100644
--- a/src/main/java/bdv/spimdata/legacy/AbstractLegacyViewerImgLoader.java
+++ b/src/main/java/bdv/spimdata/legacy/AbstractLegacyViewerImgLoader.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/spimdata/legacy/LegacyViewerImgLoader.java b/src/main/java/bdv/spimdata/legacy/LegacyViewerImgLoader.java
index 1d8558bf6357487fb87e49edb24da5a3c2a0a194..35fe12b30dc2a14fdf01024e3a6f9972cc9fba26 100644
--- a/src/main/java/bdv/spimdata/legacy/LegacyViewerImgLoader.java
+++ b/src/main/java/bdv/spimdata/legacy/LegacyViewerImgLoader.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -39,17 +38,17 @@ import net.imglib2.realtransform.AffineTransform3D;
 //@Deprecated
 public interface LegacyViewerImgLoader< T, V extends Volatile< T > > extends LegacyBasicImgLoader< T >
 {
-	public RandomAccessibleInterval< T > getImage( final ViewId view, final int level );
+	RandomAccessibleInterval< T > getImage( final ViewId view, final int level );
 
-	public RandomAccessibleInterval< V > getVolatileImage( final ViewId view, final int level );
+	RandomAccessibleInterval< V > getVolatileImage( final ViewId view, final int level );
 
-	public V getVolatileImageType();
+	V getVolatileImageType();
 
-	public double[][] getMipmapResolutions( final int setupId );
+	double[][] getMipmapResolutions( final int setupId );
 
-	public AffineTransform3D[] getMipmapTransforms( final int setupId );
+	AffineTransform3D[] getMipmapTransforms( final int setupId );
 
-	public int numMipmapLevels( final int setupId );
+	int numMipmapLevels( final int setupId );
 
-	public CacheControl getCache();
+	CacheControl getCache();
 }
diff --git a/src/main/java/bdv/spimdata/legacy/LegacyViewerImgLoaderExtWrapper.java b/src/main/java/bdv/spimdata/legacy/LegacyViewerImgLoaderExtWrapper.java
index af4d90e388fb05a53830ba3bec09fc80326d9997..3ab43ba168276a914d0afcea200be9ad780a202f 100644
--- a/src/main/java/bdv/spimdata/legacy/LegacyViewerImgLoaderExtWrapper.java
+++ b/src/main/java/bdv/spimdata/legacy/LegacyViewerImgLoaderExtWrapper.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/spimdata/legacy/LegacyViewerImgLoaderWrapper.java b/src/main/java/bdv/spimdata/legacy/LegacyViewerImgLoaderWrapper.java
index bec5d3202cfd23a0c92ed17b1625ab205147ea17..998158f7f31a501bb53ee4e11666a00cd619fc0c 100644
--- a/src/main/java/bdv/spimdata/legacy/LegacyViewerImgLoaderWrapper.java
+++ b/src/main/java/bdv/spimdata/legacy/LegacyViewerImgLoaderWrapper.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/spimdata/legacy/XmlIoSpimDataMinimalLegacy.java b/src/main/java/bdv/spimdata/legacy/XmlIoSpimDataMinimalLegacy.java
index a59c57b3d11f2b6225474398994294219a909c36..c9365a9d48571292cc5fcfb50d6edf264c4ad912 100644
--- a/src/main/java/bdv/spimdata/legacy/XmlIoSpimDataMinimalLegacy.java
+++ b/src/main/java/bdv/spimdata/legacy/XmlIoSpimDataMinimalLegacy.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/spimdata/tools/ChangeAttributeId.java b/src/main/java/bdv/spimdata/tools/ChangeAttributeId.java
index 75f6d94cc3fb222b52542e4e6dbf13465925c980..ad983b332f1de5adc000b51a93b641ee77dadba5 100644
--- a/src/main/java/bdv/spimdata/tools/ChangeAttributeId.java
+++ b/src/main/java/bdv/spimdata/tools/ChangeAttributeId.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/spimdata/tools/ChangeViewSetupId.java b/src/main/java/bdv/spimdata/tools/ChangeViewSetupId.java
index 263f573035700e5ee7d018c571d32a1fe8e1cc8a..fa135b3808d961cb8f52210a1c29641c5f1072a4 100644
--- a/src/main/java/bdv/spimdata/tools/ChangeViewSetupId.java
+++ b/src/main/java/bdv/spimdata/tools/ChangeViewSetupId.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/spimdata/tools/MergeExample.java b/src/main/java/bdv/spimdata/tools/MergeExample.java
index 8e3788cde4c0f296ed15201db85603bf97f54a24..96d8d713606de59ea0ea5635313c4443fc6b1bbb 100644
--- a/src/main/java/bdv/spimdata/tools/MergeExample.java
+++ b/src/main/java/bdv/spimdata/tools/MergeExample.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -55,7 +54,7 @@ import mpicbg.spim.data.sequence.TimePoints;
 import mpicbg.spim.data.sequence.ViewId;
 
 /**
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
 public class MergeExample
 {
diff --git a/src/main/java/bdv/spimdata/tools/MergePartitionList.java b/src/main/java/bdv/spimdata/tools/MergePartitionList.java
index 5ba2717b502845fd117b13d936bd95773110e901..1721bf0b52f413b62a481cd3d1f9cf9018bc6dd2 100644
--- a/src/main/java/bdv/spimdata/tools/MergePartitionList.java
+++ b/src/main/java/bdv/spimdata/tools/MergePartitionList.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/spimdata/tools/MergeTools.java b/src/main/java/bdv/spimdata/tools/MergeTools.java
index 2222c471f69edfea3f6fe4f6d09e06adac986b3f..4a8add0f41e3cfcb28bb07aa954a6b123cb786db 100644
--- a/src/main/java/bdv/spimdata/tools/MergeTools.java
+++ b/src/main/java/bdv/spimdata/tools/MergeTools.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/tools/HelpDialog.java b/src/main/java/bdv/tools/HelpDialog.java
index 87658494ea239366c39bbbef4d203391c5139c46..61edcb7ed41fdb417c49f216def1d1f7fa9b5a6b 100644
--- a/src/main/java/bdv/tools/HelpDialog.java
+++ b/src/main/java/bdv/tools/HelpDialog.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -29,6 +28,8 @@
  */
 package bdv.tools;
 
+import bdv.util.DelayedPackDialog;
+
 import java.awt.BorderLayout;
 import java.awt.Dimension;
 import java.awt.Frame;
@@ -43,14 +44,12 @@ import javax.swing.ActionMap;
 import javax.swing.BorderFactory;
 import javax.swing.InputMap;
 import javax.swing.JComponent;
-import javax.swing.JDialog;
 import javax.swing.JEditorPane;
 import javax.swing.JScrollPane;
 import javax.swing.KeyStroke;
 import javax.swing.ScrollPaneConstants;
-import javax.swing.WindowConstants;
 
-public class HelpDialog extends JDialog
+public class HelpDialog extends DelayedPackDialog
 {
 	private static final long serialVersionUID = 1L;
 
@@ -101,7 +100,6 @@ public class HelpDialog extends JDialog
 			am.put( hideKey, hideAction );
 
 			pack();
-			setDefaultCloseOperation( WindowConstants.HIDE_ON_CLOSE );
 		}
 		catch ( final IOException e )
 		{
diff --git a/src/main/java/bdv/tools/InitializeViewerState.java b/src/main/java/bdv/tools/InitializeViewerState.java
index 36aa6b2a4d7b2df0f62fb0c796f5a3c82f0a2662..02f1cb8562cf248382b35c69a9527ee660bc6be7 100644
--- a/src/main/java/bdv/tools/InitializeViewerState.java
+++ b/src/main/java/bdv/tools/InitializeViewerState.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -29,6 +28,10 @@
  */
 package bdv.tools;
 
+import bdv.tools.brightness.ConverterSetup;
+import bdv.util.Bounds;
+import bdv.viewer.ConverterSetups;
+import bdv.viewer.ViewerFrame;
 import java.awt.Dimension;
 
 import net.imglib2.Interval;
@@ -37,15 +40,20 @@ import net.imglib2.histogram.DiscreteFrequencyDistribution;
 import net.imglib2.histogram.Histogram1d;
 import net.imglib2.histogram.Real1dBinMapper;
 import net.imglib2.realtransform.AffineTransform3D;
+import net.imglib2.type.numeric.RealType;
+import net.imglib2.type.numeric.integer.UnsignedByteType;
 import net.imglib2.type.numeric.integer.UnsignedShortType;
 import net.imglib2.util.LinAlgHelpers;
+import net.imglib2.view.IntervalView;
 import net.imglib2.view.Views;
+
 import bdv.tools.brightness.MinMaxGroup;
 import bdv.tools.brightness.SetupAssignments;
 import bdv.util.Affine3DHelpers;
 import bdv.viewer.Source;
+import bdv.viewer.SourceAndConverter;
 import bdv.viewer.ViewerPanel;
-import bdv.viewer.state.ViewerState;
+import bdv.viewer.ViewerState;
 
 public class InitializeViewerState
 {
@@ -68,9 +76,8 @@ public class InitializeViewerState
 	public static void initTransform( final ViewerPanel viewer )
 	{
 		final Dimension dim = viewer.getDisplay().getSize();
-		final ViewerState state = viewer.getState();
-		final AffineTransform3D viewerTransform = initTransform( dim.width, dim.height, false, state );
-		viewer.setCurrentViewerTransform( viewerTransform );
+		final AffineTransform3D viewerTransform = initTransform( dim.width, dim.height, false, viewer.state().snapshot() );
+		viewer.state().setViewerTransform( viewerTransform );
 	}
 
 	/**
@@ -93,13 +100,17 @@ public class InitializeViewerState
 	 */
 	public static AffineTransform3D initTransform( final int viewerWidth, final int viewerHeight, final boolean zoomedIn, final ViewerState state )
 	{
+		final AffineTransform3D viewerTransform = new AffineTransform3D();
 		final double cX = viewerWidth / 2.0;
 		final double cY = viewerHeight / 2.0;
 
-		final Source< ? > source = state.getSources().get( state.getCurrentSource() ).getSpimSource();
+		final SourceAndConverter< ? > current = state.getCurrentSource();
+		if ( current == null )
+			return viewerTransform;
+		final Source< ? > source = current.getSpimSource();
 		final int timepoint = state.getCurrentTimepoint();
 		if ( !source.isPresent( timepoint ) )
-			return new AffineTransform3D();
+			return viewerTransform;
 
 		final AffineTransform3D sourceTransform = new AffineTransform3D();
 		source.getSourceTransform( timepoint, 0, sourceTransform );
@@ -133,7 +144,6 @@ public class InitializeViewerState
 		LinAlgHelpers.scale( translation, -1, translation );
 		LinAlgHelpers.setCol( 3, translation, m );
 
-		final AffineTransform3D viewerTransform = new AffineTransform3D();
 		viewerTransform.set( m );
 
 		// scale
@@ -157,51 +167,97 @@ public class InitializeViewerState
 		return viewerTransform;
 	}
 
-	public static void initBrightness( final double cumulativeMinCutoff, final double cumulativeMaxCutoff, final ViewerPanel viewer, final SetupAssignments setupAssignments )
+	public static void initBrightness( final double cumulativeMinCutoff, final double cumulativeMaxCutoff, final ViewerFrame viewerFrame )
+	{
+		initBrightness( cumulativeMinCutoff, cumulativeMaxCutoff, viewerFrame.getViewerPanel().state().snapshot(), viewerFrame.getConverterSetups() );
+	}
+
+	public static void initBrightness( final double cumulativeMinCutoff, final double cumulativeMaxCutoff, final ViewerState state, final ConverterSetups converterSetups )
 	{
-		initBrightness( cumulativeMinCutoff, cumulativeMaxCutoff, viewer.getState(), setupAssignments );
+		final SourceAndConverter< ? > current = state.getCurrentSource();
+		if ( current == null )
+			return;
+		final Source< ? > source = current.getSpimSource();
+		final int timepoint = state.getCurrentTimepoint();
+		final Bounds bounds = estimateSourceRange( source, timepoint, cumulativeMinCutoff, cumulativeMaxCutoff );
+		for ( SourceAndConverter< ? > s : state.getSources() )
+		{
+			final ConverterSetup setup = converterSetups.getConverterSetup( s );
+			setup.setDisplayRange( bounds.getMinBound(), bounds.getMaxBound() );
+		}
 	}
 
 	/**
-	 * TODO
-	 *
 	 * @param cumulativeMinCutoff
+	 * 		fraction of pixels that are allowed to be saturated at the lower end of the range.
 	 * @param cumulativeMaxCutoff
-	 * @param state
-	 * @param setupAssignments
+	 * 		fraction of pixels that are allowed to be saturated at the upper end of the range.
 	 */
-	public static void initBrightness( final double cumulativeMinCutoff, final double cumulativeMaxCutoff, final ViewerState state, final SetupAssignments setupAssignments )
+	public static Bounds estimateSourceRange( final Source< ? > source, final int timepoint, final double cumulativeMinCutoff, final double cumulativeMaxCutoff )
 	{
-		final Source< ? > source = state.getSources().get( state.getCurrentSource() ).getSpimSource();
-		final int timepoint = state.getCurrentTimepoint();
-		if ( !source.isPresent( timepoint ) )
-			return;
-		if ( !UnsignedShortType.class.isInstance( source.getType() ) )
-			return;
-		@SuppressWarnings( "unchecked" )
-		final RandomAccessibleInterval< UnsignedShortType > img = ( RandomAccessibleInterval< UnsignedShortType > ) source.getSource( timepoint, source.getNumMipmapLevels() - 1 );
-		final long z = ( img.min( 2 ) + img.max( 2 ) + 1 ) / 2;
-
-		final int numBins = 6535;
-		final Histogram1d< UnsignedShortType > histogram = new Histogram1d<>( Views.iterable( Views.hyperSlice( img, 2, z ) ), new Real1dBinMapper< UnsignedShortType >( 0, 65535, numBins, false ) );
-		final DiscreteFrequencyDistribution dfd = histogram.dfd();
-		final long[] bin = new long[] { 0 };
-		double cumulative = 0;
-		int i = 0;
-		for ( ; i < numBins && cumulative < cumulativeMinCutoff; ++i )
-		{
-			bin[ 0 ] = i;
-			cumulative += dfd.relativeFrequency( bin );
-		}
-		final int min = i * 65535 / numBins;
-		for ( ; i < numBins && cumulative < cumulativeMaxCutoff; ++i )
+		final Object type = source.getType();
+		if ( type instanceof UnsignedShortType && source.isPresent( timepoint ) )
 		{
-			bin[ 0 ] = i;
-			cumulative += dfd.relativeFrequency( bin );
+			@SuppressWarnings( "unchecked" )
+			final RandomAccessibleInterval< UnsignedShortType > img = ( RandomAccessibleInterval< UnsignedShortType > ) source.getSource( timepoint, source.getNumMipmapLevels() - 1 );
+			final long z = ( img.min( 2 ) + img.max( 2 ) + 1 ) / 2;
+
+			final int numBins = 6535;
+			final Histogram1d< ? > histogram = new Histogram1d<>( Views.hyperSlice( img, 2, z ), new Real1dBinMapper<>( 0, 65535, numBins, false ) );
+			final DiscreteFrequencyDistribution dfd = histogram.dfd();
+			final long[] bin = new long[] { 0 };
+			double cumulative = 0;
+			int i = 0;
+			for ( ; i < numBins && cumulative < cumulativeMinCutoff; ++i )
+			{
+				bin[ 0 ] = i;
+				cumulative += dfd.relativeFrequency( bin );
+			}
+			final int min = i * 65535 / numBins;
+			for ( ; i < numBins && cumulative < cumulativeMaxCutoff; ++i )
+			{
+				bin[ 0 ] = i;
+				cumulative += dfd.relativeFrequency( bin );
+			}
+			final int max = i * 65535 / numBins;
+			return new Bounds( min, max );
 		}
-		final int max = i * 65535 / numBins;
+		else if ( type instanceof UnsignedByteType )
+			return new Bounds( 0, 255 );
+		else
+			return new Bounds( 0, 65535 );
+	}
+
+	@Deprecated
+	public static AffineTransform3D initTransform( final int viewerWidth, final int viewerHeight, final boolean zoomedIn, final bdv.viewer.state.ViewerState state )
+	{
+		return initTransform( viewerWidth, viewerHeight, zoomedIn, state.getState() );
+	}
+
+	@Deprecated
+	public static void initBrightness( final double cumulativeMinCutoff, final double cumulativeMaxCutoff, final bdv.viewer.state.ViewerState state, final SetupAssignments setupAssignments )
+	{
+		initBrightness( cumulativeMinCutoff, cumulativeMaxCutoff, state.getState(), setupAssignments );
+	}
+
+	@Deprecated
+	public static void initBrightness( final double cumulativeMinCutoff, final double cumulativeMaxCutoff, final ViewerPanel viewer, final SetupAssignments setupAssignments )
+	{
+		initBrightness( cumulativeMinCutoff, cumulativeMaxCutoff, viewer.state().snapshot(), setupAssignments );
+	}
+
+	@Deprecated
+	public static void initBrightness( final double cumulativeMinCutoff, final double cumulativeMaxCutoff, final ViewerState state, final SetupAssignments setupAssignments )
+	{
+		final SourceAndConverter< ? > current = state.getCurrentSource();
+		if ( current == null )
+			return;
+
+		final Source< ? > source = current.getSpimSource();
+		final int timepoint = state.getCurrentTimepoint();
+		final Bounds bounds = estimateSourceRange( source, timepoint, cumulativeMinCutoff, cumulativeMaxCutoff );
 		final MinMaxGroup minmax = setupAssignments.getMinMaxGroups().get( 0 );
-		minmax.getMinBoundedValue().setCurrentValue( min );
-		minmax.getMaxBoundedValue().setCurrentValue( max );
+		minmax.getMinBoundedValue().setCurrentValue( bounds.getMinBound() );
+		minmax.getMaxBoundedValue().setCurrentValue( bounds.getMaxBound() );
 	}
 }
diff --git a/src/main/java/bdv/tools/RecordMaxProjectionDialog.java b/src/main/java/bdv/tools/RecordMaxProjectionDialog.java
index 205d8e99bd3b78a6ec8e1ddfee59dfe067357ae7..3ec543035640a7494229a28b3e862ba33cc40d46 100644
--- a/src/main/java/bdv/tools/RecordMaxProjectionDialog.java
+++ b/src/main/java/bdv/tools/RecordMaxProjectionDialog.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -29,6 +28,16 @@
  */
 package bdv.tools;
 
+import bdv.cache.CacheControl;
+import bdv.export.ProgressWriter;
+import bdv.util.DelayedPackDialog;
+import bdv.util.Prefs;
+import bdv.viewer.BasicViewerState;
+import bdv.viewer.ViewerPanel;
+import bdv.viewer.ViewerState;
+import bdv.viewer.overlay.ScaleBarOverlayRenderer;
+import bdv.viewer.render.awt.BufferedImageRenderResult;
+import bdv.viewer.render.MultiResolutionRenderer;
 import java.awt.BorderLayout;
 import java.awt.Frame;
 import java.awt.Graphics;
@@ -40,7 +49,6 @@ import java.awt.image.BufferedImage;
 import java.awt.image.DataBufferInt;
 import java.io.File;
 import java.io.IOException;
-
 import javax.imageio.ImageIO;
 import javax.swing.AbstractAction;
 import javax.swing.Action;
@@ -49,7 +57,6 @@ import javax.swing.BoxLayout;
 import javax.swing.InputMap;
 import javax.swing.JButton;
 import javax.swing.JComponent;
-import javax.swing.JDialog;
 import javax.swing.JFileChooser;
 import javax.swing.JLabel;
 import javax.swing.JPanel;
@@ -57,29 +64,19 @@ import javax.swing.JSpinner;
 import javax.swing.JTextField;
 import javax.swing.KeyStroke;
 import javax.swing.SpinnerNumberModel;
-import javax.swing.WindowConstants;
 import javax.swing.event.ChangeEvent;
 import javax.swing.event.ChangeListener;
-
 import net.imglib2.Cursor;
 import net.imglib2.display.screenimage.awt.ARGBScreenImage;
 import net.imglib2.img.Img;
 import net.imglib2.img.array.ArrayImgs;
 import net.imglib2.realtransform.AffineTransform3D;
 import net.imglib2.type.numeric.ARGBType;
-import net.imglib2.ui.OverlayRenderer;
-import net.imglib2.ui.PainterThread;
-import net.imglib2.ui.RenderTarget;
+import bdv.viewer.OverlayRenderer;
+import bdv.viewer.render.RenderTarget;
 import net.imglib2.util.LinAlgHelpers;
-import bdv.cache.CacheControl;
-import bdv.export.ProgressWriter;
-import bdv.util.Prefs;
-import bdv.viewer.ViewerPanel;
-import bdv.viewer.overlay.ScaleBarOverlayRenderer;
-import bdv.viewer.render.MultiResolutionRenderer;
-import bdv.viewer.state.ViewerState;
 
-public class RecordMaxProjectionDialog extends JDialog implements OverlayRenderer
+public class RecordMaxProjectionDialog extends DelayedPackDialog implements OverlayRenderer
 {
 	private static final long serialVersionUID = 1L;
 
@@ -107,7 +104,7 @@ public class RecordMaxProjectionDialog extends JDialog implements OverlayRendere
 	{
 		super( owner, "record max projection movie", false );
 		this.viewer = viewer;
-		maxTimepoint = viewer.getState().getNumTimepoints() - 1;
+		maxTimepoint = viewer.state().getNumTimepoints() - 1;
 		this.progressWriter = progressWriter;
 
 		final JPanel boxes = new JPanel();
@@ -277,7 +274,6 @@ public class RecordMaxProjectionDialog extends JDialog implements OverlayRendere
 		am.put( hideKey, hideAction );
 
 		pack();
-		setDefaultCloseOperation( WindowConstants.HIDE_ON_CLOSE );
 	}
 
 	/**
@@ -285,7 +281,7 @@ public class RecordMaxProjectionDialog extends JDialog implements OverlayRendere
 	 */
 	public void recordMovie( final int width, final int height, final int minTimepointIndex, final int maxTimepointIndex, final double stepSize, final int numSteps, final File dir ) throws IOException
 	{
-		final ViewerState renderState = viewer.getState();
+		final ViewerState renderState = new BasicViewerState( viewer.state().snapshot() );
 		final int canvasW = viewer.getDisplay().getWidth();
 		final int canvasH = viewer.getDisplay().getHeight();
 
@@ -314,14 +310,11 @@ public class RecordMaxProjectionDialog extends JDialog implements OverlayRendere
 
 		final ScaleBarOverlayRenderer scalebar = Prefs.showScaleBarInMovie() ? new ScaleBarOverlayRenderer() : null;
 
-		class MyTarget implements RenderTarget
+		class MyTarget implements RenderTarget< BufferedImageRenderResult >
 		{
-			final ARGBScreenImage accumulated;
+			final ARGBScreenImage accumulated = new ARGBScreenImage( width, height );
 
-			public MyTarget()
-			{
-				accumulated = new ARGBScreenImage( width, height );
-			}
+			final BufferedImageRenderResult renderResult = new BufferedImageRenderResult();
 
 			public void clear()
 			{
@@ -330,8 +323,21 @@ public class RecordMaxProjectionDialog extends JDialog implements OverlayRendere
 			}
 
 			@Override
-			public BufferedImage setBufferedImage( final BufferedImage bufferedImage )
+			public BufferedImageRenderResult getReusableRenderResult()
+			{
+				return renderResult;
+			}
+
+			@Override
+			public BufferedImageRenderResult createRenderResult()
+			{
+				return new BufferedImageRenderResult();
+			}
+
+			@Override
+			public void setRenderResult( final BufferedImageRenderResult renderResult )
 			{
+				final BufferedImage bufferedImage = renderResult.getBufferedImage();
 				final Img< ARGBType > argbs = ArrayImgs.argbs( ( ( DataBufferInt ) bufferedImage.getData().getDataBuffer() ).getData(), width, height );
 				final Cursor< ARGBType > c = argbs.cursor();
 				for ( final ARGBType acc : accumulated )
@@ -344,7 +350,6 @@ public class RecordMaxProjectionDialog extends JDialog implements OverlayRendere
 							Math.max( ARGBType.blue( in ), ARGBType.blue( current ) ),
 							Math.max( ARGBType.alpha( in ), ARGBType.alpha( current ) )	) );
 				}
-				return null;
 			}
 
 			@Override
@@ -361,7 +366,7 @@ public class RecordMaxProjectionDialog extends JDialog implements OverlayRendere
 		}
 		final MyTarget target = new MyTarget();
 		final MultiResolutionRenderer renderer = new MultiResolutionRenderer(
-				target, new PainterThread( null ), new double[] { 1 }, 0, false, 1, null, false,
+				target, () -> {}, new double[] { 1 }, 0, 1, null, false,
 				viewer.getOptionValues().getAccumulateProjectorFactory(), new CacheControl.Dummy() );
 		progressWriter.setProgress( 0 );
 		for ( int timepoint = minTimepointIndex; timepoint <= maxTimepointIndex; ++timepoint )
diff --git a/src/main/java/bdv/tools/RecordMovieDialog.java b/src/main/java/bdv/tools/RecordMovieDialog.java
index 4f6b47aa4a167017c6bdd830d71efb3337101008..cf35bcbd141638108ea1cde6d74bec026dd63084 100644
--- a/src/main/java/bdv/tools/RecordMovieDialog.java
+++ b/src/main/java/bdv/tools/RecordMovieDialog.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -29,6 +28,16 @@
  */
 package bdv.tools;
 
+import bdv.cache.CacheControl;
+import bdv.export.ProgressWriter;
+import bdv.util.DelayedPackDialog;
+import bdv.util.Prefs;
+import bdv.viewer.BasicViewerState;
+import bdv.viewer.ViewerPanel;
+import bdv.viewer.ViewerState;
+import bdv.viewer.overlay.ScaleBarOverlayRenderer;
+import bdv.viewer.render.awt.BufferedImageRenderResult;
+import bdv.viewer.render.MultiResolutionRenderer;
 import java.awt.BorderLayout;
 import java.awt.Frame;
 import java.awt.Graphics;
@@ -39,7 +48,6 @@ import java.awt.event.KeyEvent;
 import java.awt.image.BufferedImage;
 import java.io.File;
 import java.io.IOException;
-
 import javax.imageio.ImageIO;
 import javax.swing.AbstractAction;
 import javax.swing.Action;
@@ -48,7 +56,6 @@ import javax.swing.BoxLayout;
 import javax.swing.InputMap;
 import javax.swing.JButton;
 import javax.swing.JComponent;
-import javax.swing.JDialog;
 import javax.swing.JFileChooser;
 import javax.swing.JLabel;
 import javax.swing.JPanel;
@@ -56,23 +63,13 @@ import javax.swing.JSpinner;
 import javax.swing.JTextField;
 import javax.swing.KeyStroke;
 import javax.swing.SpinnerNumberModel;
-import javax.swing.WindowConstants;
 import javax.swing.event.ChangeEvent;
 import javax.swing.event.ChangeListener;
-
-import bdv.cache.CacheControl;
-import bdv.export.ProgressWriter;
-import bdv.util.Prefs;
-import bdv.viewer.ViewerPanel;
-import bdv.viewer.overlay.ScaleBarOverlayRenderer;
-import bdv.viewer.render.MultiResolutionRenderer;
-import bdv.viewer.state.ViewerState;
 import net.imglib2.realtransform.AffineTransform3D;
-import net.imglib2.ui.OverlayRenderer;
-import net.imglib2.ui.PainterThread;
-import net.imglib2.ui.RenderTarget;
+import bdv.viewer.OverlayRenderer;
+import bdv.viewer.render.RenderTarget;
 
-public class RecordMovieDialog extends JDialog implements OverlayRenderer
+public class RecordMovieDialog extends DelayedPackDialog implements OverlayRenderer
 {
 	private static final long serialVersionUID = 1L;
 
@@ -96,7 +93,7 @@ public class RecordMovieDialog extends JDialog implements OverlayRenderer
 	{
 		super( owner, "record movie", false );
 		this.viewer = viewer;
-		maxTimepoint = viewer.getState().getNumTimepoints() - 1;
+		maxTimepoint = viewer.state().getNumTimepoints() - 1;
 		this.progressWriter = progressWriter;
 
 		final JPanel boxes = new JPanel();
@@ -250,12 +247,11 @@ public class RecordMovieDialog extends JDialog implements OverlayRenderer
 		am.put( hideKey, hideAction );
 
 		pack();
-		setDefaultCloseOperation( WindowConstants.HIDE_ON_CLOSE );
 	}
 
 	public void recordMovie( final int width, final int height, final int minTimepointIndex, final int maxTimepointIndex, final File dir ) throws IOException
 	{
-		final ViewerState renderState = viewer.getState();
+		final ViewerState renderState = new BasicViewerState( viewer.state().snapshot() );
 		final int canvasW = viewer.getDisplay().getWidth();
 		final int canvasH = viewer.getDisplay().getHeight();
 
@@ -270,17 +266,26 @@ public class RecordMovieDialog extends JDialog implements OverlayRenderer
 
 		final ScaleBarOverlayRenderer scalebar = Prefs.showScaleBarInMovie() ? new ScaleBarOverlayRenderer() : null;
 
-		class MyTarget implements RenderTarget
+		class MyTarget implements RenderTarget< BufferedImageRenderResult >
 		{
-			BufferedImage bi;
+			final BufferedImageRenderResult renderResult = new BufferedImageRenderResult();
+
+			@Override
+			public BufferedImageRenderResult getReusableRenderResult()
+			{
+				return renderResult;
+			}
 
 			@Override
-			public BufferedImage setBufferedImage( final BufferedImage bufferedImage )
+			public BufferedImageRenderResult createRenderResult()
 			{
-				bi = bufferedImage;
-				return null;
+				return new BufferedImageRenderResult();
 			}
 
+			@Override
+			public void setRenderResult( final BufferedImageRenderResult renderResult )
+			{}
+
 			@Override
 			public int getWidth()
 			{
@@ -295,7 +300,7 @@ public class RecordMovieDialog extends JDialog implements OverlayRenderer
 		}
 		final MyTarget target = new MyTarget();
 		final MultiResolutionRenderer renderer = new MultiResolutionRenderer(
-				target, new PainterThread( null ), new double[] { 1 }, 0, false, 1, null, false,
+				target, () -> {}, new double[] { 1 }, 0, 1, null, false,
 				viewer.getOptionValues().getAccumulateProjectorFactory(), new CacheControl.Dummy() );
 		progressWriter.setProgress( 0 );
 		for ( int timepoint = minTimepointIndex; timepoint <= maxTimepointIndex; ++timepoint )
@@ -304,15 +309,16 @@ public class RecordMovieDialog extends JDialog implements OverlayRenderer
 			renderer.requestRepaint();
 			renderer.paint( renderState );
 
+			final BufferedImage bi = target.renderResult.getBufferedImage();
 			if ( Prefs.showScaleBarInMovie() )
 			{
-				final Graphics2D g2 = target.bi.createGraphics();
+				final Graphics2D g2 = bi.createGraphics();
 				g2.setClip( 0, 0, width, height );
 				scalebar.setViewerState( renderState );
 				scalebar.paint( g2 );
 			}
 
-			ImageIO.write( target.bi, "png", new File( String.format( "%s/img-%03d.png", dir, timepoint ) ) );
+			ImageIO.write( bi, "png", new File( String.format( "%s/img-%03d.png", dir, timepoint ) ) );
 			progressWriter.setProgress( ( double ) (timepoint - minTimepointIndex + 1) / (maxTimepointIndex - minTimepointIndex + 1) );
 		}
 	}
diff --git a/src/main/java/bdv/tools/ToggleDialogAction.java b/src/main/java/bdv/tools/ToggleDialogAction.java
index 09fb113a8e263325bfe1d48a711502abf659f9a5..d2ce237e87b783855c30c32c5087be0406494806 100644
--- a/src/main/java/bdv/tools/ToggleDialogAction.java
+++ b/src/main/java/bdv/tools/ToggleDialogAction.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/tools/VisibilityAndGroupingDialog.java b/src/main/java/bdv/tools/VisibilityAndGroupingDialog.java
index d6326b403bae9a782be8d1b04c8c823099ff34fb..b1da27e26f79a5bd9cfe80c2da58c2047b44cbee 100644
--- a/src/main/java/bdv/tools/VisibilityAndGroupingDialog.java
+++ b/src/main/java/bdv/tools/VisibilityAndGroupingDialog.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -29,16 +28,13 @@
  */
 package bdv.tools;
 
-import static bdv.viewer.VisibilityAndGrouping.Event.CURRENT_GROUP_CHANGED;
-import static bdv.viewer.VisibilityAndGrouping.Event.CURRENT_SOURCE_CHANGED;
-import static bdv.viewer.VisibilityAndGrouping.Event.DISPLAY_MODE_CHANGED;
-import static bdv.viewer.VisibilityAndGrouping.Event.GROUP_ACTIVITY_CHANGED;
-import static bdv.viewer.VisibilityAndGrouping.Event.GROUP_NAME_CHANGED;
-import static bdv.viewer.VisibilityAndGrouping.Event.NUM_SOURCES_CHANGED;
-import static bdv.viewer.VisibilityAndGrouping.Event.SOURCE_ACTVITY_CHANGED;
-import static bdv.viewer.VisibilityAndGrouping.Event.SOURCE_TO_GROUP_ASSIGNMENT_CHANGED;
-import static bdv.viewer.VisibilityAndGrouping.Event.VISIBILITY_CHANGED;
-
+import bdv.util.DelayedPackDialog;
+import bdv.viewer.SourceAndConverter;
+import bdv.viewer.ViewerState;
+import bdv.viewer.VisibilityAndGrouping;
+import bdv.viewer.SourceGroup;
+import bdv.viewer.ViewerStateChange;
+import bdv.viewer.ViewerStateChangeListener;
 import java.awt.BorderLayout;
 import java.awt.Frame;
 import java.awt.GridBagConstraints;
@@ -46,11 +42,17 @@ import java.awt.GridBagLayout;
 import java.awt.Insets;
 import java.awt.Window;
 import java.awt.event.ActionEvent;
-import java.awt.event.ActionListener;
+import java.awt.event.ComponentAdapter;
+import java.awt.event.ComponentEvent;
 import java.awt.event.KeyEvent;
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
-
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.BooleanSupplier;
+import java.util.function.Consumer;
 import javax.swing.AbstractAction;
 import javax.swing.Action;
 import javax.swing.ActionMap;
@@ -60,22 +62,17 @@ import javax.swing.ButtonGroup;
 import javax.swing.InputMap;
 import javax.swing.JCheckBox;
 import javax.swing.JComponent;
-import javax.swing.JDialog;
 import javax.swing.JLabel;
 import javax.swing.JPanel;
 import javax.swing.JRadioButton;
 import javax.swing.JTextField;
 import javax.swing.KeyStroke;
 import javax.swing.SwingUtilities;
-import javax.swing.WindowConstants;
 import javax.swing.event.DocumentEvent;
 import javax.swing.event.DocumentListener;
 
-import bdv.viewer.VisibilityAndGrouping;
-import bdv.viewer.VisibilityAndGrouping.Event;
-import bdv.viewer.state.SourceGroup;
-
-public class VisibilityAndGroupingDialog extends JDialog
+@Deprecated
+public class VisibilityAndGroupingDialog extends DelayedPackDialog
 {
 	private static final long serialVersionUID = 1L;
 
@@ -86,11 +83,15 @@ public class VisibilityAndGroupingDialog extends JDialog
 	private final ModePanel modePanel;
 
 	public VisibilityAndGroupingDialog( final Frame owner, final VisibilityAndGrouping visibilityAndGrouping )
+	{
+		this( owner, visibilityAndGrouping.getState() );
+	}
+
+	public VisibilityAndGroupingDialog( final Frame owner, final ViewerState state )
 	{
 		super( owner, "visibility and grouping", false );
 
-		visibilityPanel = new VisibilityPanel( visibilityAndGrouping );
-		visibilityAndGrouping.addUpdateListener( visibilityPanel );
+		visibilityPanel = new VisibilityPanel( state, this::isVisible );
 		visibilityPanel.setBorder( BorderFactory.createCompoundBorder(
 				BorderFactory.createEmptyBorder( 4, 2, 4, 2 ),
 				BorderFactory.createCompoundBorder(
@@ -99,8 +100,7 @@ public class VisibilityAndGroupingDialog extends JDialog
 								"visibility" ),
 						BorderFactory.createEmptyBorder( 2, 2, 2, 2 ) ) ) );
 
-		groupingPanel = new GroupingPanel( visibilityAndGrouping );
-		visibilityAndGrouping.addUpdateListener( groupingPanel );
+		groupingPanel = new GroupingPanel( state, this::isVisible );
 		groupingPanel.setBorder( BorderFactory.createCompoundBorder(
 				BorderFactory.createEmptyBorder( 4, 2, 4, 2 ),
 				BorderFactory.createCompoundBorder(
@@ -109,15 +109,14 @@ public class VisibilityAndGroupingDialog extends JDialog
 								"grouping" ),
 						BorderFactory.createEmptyBorder( 2, 2, 2, 2 ) ) ) );
 
-		modePanel = new ModePanel( visibilityAndGrouping );
-		visibilityAndGrouping.addUpdateListener( modePanel );
+		modePanel = new ModePanel( state, this::isVisible );
 
 		final JPanel content = new JPanel();
 		content.setLayout( new BoxLayout( content, BoxLayout.PAGE_AXIS ) );
 		content.add( visibilityPanel );
 		content.add( groupingPanel );
 		content.add( modePanel );
-		getContentPane().add( content, BorderLayout.NORTH );
+		add( content, BorderLayout.NORTH );
 
 		final ActionMap am = getRootPane().getActionMap();
 		final InputMap im = getRootPane().getInputMap( JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT );
@@ -135,8 +134,18 @@ public class VisibilityAndGroupingDialog extends JDialog
 		im.put( KeyStroke.getKeyStroke( KeyEvent.VK_ESCAPE, 0 ), hideKey );
 		am.put( hideKey, hideAction );
 
+		addComponentListener( new ComponentAdapter()
+		{
+			@Override
+			public void componentShown( final ComponentEvent e )
+			{
+				visibilityPanel.shown();
+				groupingPanel.shown();
+				modePanel.shown();
+			}
+		} );
+
 		pack();
-		setDefaultCloseOperation( WindowConstants.HIDE_ON_CLOSE );
 	}
 
 	public void update()
@@ -146,174 +155,206 @@ public class VisibilityAndGroupingDialog extends JDialog
 		modePanel.update();
 	}
 
-	public static class VisibilityPanel extends JPanel implements VisibilityAndGrouping.UpdateListener
+	public static class VisibilityPanel extends JPanel implements ViewerStateChangeListener
 	{
 		private static final long serialVersionUID = 1L;
 
-		private final VisibilityAndGrouping visibility;
+		private final ViewerState state;
+
+		private final Map< SourceAndConverter< ? >, JRadioButton > currentButtonsMap = new HashMap<>();
 
-		private final ArrayList< JRadioButton > currentButtons;
+		private final ArrayList< Consumer< Set< SourceAndConverter< ? > > > > updateActiveBoxes = new ArrayList<>();
 
-		private final ArrayList< JCheckBox > fusedBoxes;
+		private final ArrayList< Consumer< Set< SourceAndConverter< ? > > > > updateVisibleBoxes = new ArrayList<>();
 
-		private final ArrayList< JCheckBox > visibleBoxes;
+		private final BooleanSupplier isVisible;
 
-		public VisibilityPanel( final VisibilityAndGrouping visibilityAndGrouping )
+		public VisibilityPanel( final ViewerState state, BooleanSupplier isVisible )
 		{
 			super( new GridBagLayout() );
-			this.visibility = visibilityAndGrouping;
-			currentButtons = new ArrayList<>();
-			fusedBoxes = new ArrayList<>();
-			visibleBoxes = new ArrayList<>();
-			recreateContent();
-			update();
+			this.state = state;
+			this.isVisible = isVisible;
+			state.changeListeners().add( this );
 		}
 
-		protected void recreateContent()
+		private void shown()
 		{
-			removeAll();
-			currentButtons.clear();
-			fusedBoxes.clear();
-			visibleBoxes.clear();
+			if ( recreateContentPending.getAndSet( false ) )
+				recreateContentNow();
+			if ( updatePending.getAndSet( false ) )
+				updateNow();
+		}
 
-			final int numSources = visibility.numSources();
-			final GridBagConstraints c = new GridBagConstraints();
-			c.insets = new Insets( 0, 5, 0, 5 );
+		private final AtomicBoolean recreateContentPending = new AtomicBoolean( true );
 
-			// source names
-			c.gridx = 0;
-			c.gridy = 0;
-			add( new JLabel( "source" ), c );
-			c.anchor = GridBagConstraints.LINE_END;
-			c.gridy = GridBagConstraints.RELATIVE;
-			for ( int i = 0; i < numSources; ++i )
-				add( new JLabel( visibility.getSources().get( i ).getSpimSource().getName() ), c );
-
-			// "current" radio-buttons
-			c.anchor = GridBagConstraints.CENTER;
-			c.gridx = 1;
-			c.gridy = 0;
-			add( new JLabel( "current" ), c );
-			c.gridy = GridBagConstraints.RELATIVE;
-			final ButtonGroup currentButtonGroup = new ButtonGroup();
-			for ( int i = 0; i < numSources; ++i )
+		private void recreateContent()
+		{
+			if ( isVisible.getAsBoolean() )
 			{
-				final JRadioButton b = new JRadioButton();
-				final int sourceIndex = i;
-				b.addActionListener( new ActionListener()
-				{
-					@Override
-					public void actionPerformed( final ActionEvent e )
-					{
-						if ( b.isSelected() )
-							visibility.setCurrentSource( sourceIndex );
-					}
-				} );
-				currentButtons.add( b );
-				currentButtonGroup.add( b );
-				add( b, c );
+				recreateContentNow();
+				recreateContentPending.set( false );
 			}
+			else
+				recreateContentPending.set( true );
+		}
 
-			// "active in fused" check-boxes
-			c.gridx = 2;
-			c.gridy = 0;
-			add( new JLabel( "active in fused" ), c );
-			c.gridy = GridBagConstraints.RELATIVE;
-			for ( int i = 0; i < numSources; ++i )
+		private void recreateContentNow()
+		{
+			synchronized ( state )
 			{
-				final JCheckBox b = new JCheckBox();
-				final int sourceIndex = i;
-				b.addActionListener( new ActionListener()
+				removeAll();
+				currentButtonsMap.clear();
+				updateActiveBoxes.clear();
+				updateVisibleBoxes.clear();
+
+				final List< SourceAndConverter< ? > > sources = state.getSources();
+
+				final GridBagConstraints c = new GridBagConstraints();
+				c.insets = new Insets( 0, 5, 0, 5 );
+
+				// source names
+				c.gridx = 0;
+				c.gridy = 0;
+				add( new JLabel( "source" ), c );
+				c.anchor = GridBagConstraints.LINE_END;
+				c.gridy = GridBagConstraints.RELATIVE;
+				for ( final SourceAndConverter< ? > source : sources )
+					add( new JLabel( source.getSpimSource().getName() ), c );
+
+				// "current" radio-buttons
+				c.anchor = GridBagConstraints.CENTER;
+				c.gridx = 1;
+				c.gridy = 0;
+				add( new JLabel( "current" ), c );
+				c.gridy = GridBagConstraints.RELATIVE;
+				final ButtonGroup currentButtonGroup = new ButtonGroup();
+				for ( final SourceAndConverter< ? > source : sources )
 				{
-					@Override
-					public void actionPerformed( final ActionEvent e )
-					{
-						visibility.setSourceActive( sourceIndex, b.isSelected() );
-					}
-				} );
-				fusedBoxes.add( b );
-				add( b, c );
+					final JRadioButton b = new JRadioButton();
+					b.addActionListener( e -> {
+						if ( b.isSelected() )
+							state.setCurrentSource( source );
+					} );
+					currentButtonsMap.put( source, b );
+					currentButtonGroup.add( b );
+					add( b, c );
+				}
+
+				// "active in fused" check-boxes
+				c.gridx = 2;
+				c.gridy = 0;
+				add( new JLabel( "active in fused" ), c );
+				c.gridy = GridBagConstraints.RELATIVE;
+				for ( final SourceAndConverter< ? > source : sources )
+				{
+					final JCheckBox b = new JCheckBox();
+					b.addActionListener( e -> state.setSourceActive( source, b.isSelected() ) );
+					updateActiveBoxes.add( active -> b.setSelected( active.contains( source ) ) );
+					add( b, c );
+				}
+
+				// "currently visible" check-boxes
+				c.gridx = 3;
+				c.gridy = 0;
+				add( new JLabel( "visible" ), c );
+				c.gridy = GridBagConstraints.RELATIVE;
+				for ( final SourceAndConverter< ? > source : sources )
+				{
+					final JCheckBox b = new JCheckBox();
+					updateVisibleBoxes.add( visible -> b.setSelected( visible.contains( source ) ) );
+					b.setEnabled( false );
+					add( b, c );
+				}
+
+				invalidate();
+				final Window frame = SwingUtilities.getWindowAncestor( this );
+				if ( frame != null )
+					frame.pack();
+
+				update();
 			}
+		}
 
-			// "currently visible" check-boxes
-			c.gridx = 3;
-			c.gridy = 0;
-			add( new JLabel( "visible" ), c );
-			c.gridy = GridBagConstraints.RELATIVE;
-			for ( int i = 0; i < numSources; ++i )
+		private final AtomicBoolean updatePending = new AtomicBoolean( true );
+
+		private void update()
+		{
+			if ( isVisible.getAsBoolean() )
 			{
-				final JCheckBox b = new JCheckBox();
-				visibleBoxes.add( b );
-				b.setEnabled( false );
-				add( b, c );
+				updateNow();
+				updatePending.set( false );
 			}
-
-			invalidate();
-			final Window frame = SwingUtilities.getWindowAncestor( this );
-			if ( frame != null )
-				frame.pack();
+			else
+				updatePending.set( true );
 		}
 
-		protected void update()
+		private void updateNow()
 		{
-			synchronized ( visibility )
-			{
-				final int numSources = visibility.numSources();
-				if ( currentButtons.size() != numSources )
-					recreateContent();
+			final SourceAndConverter< ? > currentSource = state.getCurrentSource();
+			if ( currentSource == null )
+				currentButtonsMap.values().forEach( b -> b.setSelected( false ) );
+			else
+				currentButtonsMap.get( currentSource ).setSelected( true );
 
-				if ( numSources > 0 )
-				{
-					currentButtons.get( visibility.getCurrentSource() ).setSelected( true );
-					for ( int i = 0; i < numSources; ++i )
-					{
-						fusedBoxes.get( i ).setSelected( visibility.isSourceActive( i ) );
-						visibleBoxes.get( i ).setSelected( visibility.isSourceVisible( i ) );
-					}
-				}
-			}
+			final Set< SourceAndConverter< ? > > activeSources = state.getActiveSources();
+			updateActiveBoxes.forEach( c -> c.accept( activeSources ) );
+
+			final Set< SourceAndConverter< ? > > visibleSources = state.getVisibleSources();
+			updateVisibleBoxes.forEach( c -> c.accept( visibleSources ) );
 		}
 
 		@Override
-		public void visibilityChanged( final Event e )
+		public void viewerStateChanged( final ViewerStateChange change )
 		{
-			synchronized ( visibility )
-			{
-				if ( currentButtons.size() != visibility.numSources() )
-					recreateContent();
+			final AtomicBoolean pendingUpdate = new AtomicBoolean();
+			final AtomicBoolean pendingRecreate = new AtomicBoolean();
 
-				switch ( e.id )
-				{
-				case CURRENT_SOURCE_CHANGED:
-				case SOURCE_ACTVITY_CHANGED:
-				case VISIBILITY_CHANGED:
-				case NUM_SOURCES_CHANGED:
-					update();
-					break;
-				}
+			switch ( change )
+			{
+			case CURRENT_SOURCE_CHANGED:
+			case SOURCE_ACTIVITY_CHANGED:
+			case VISIBILITY_CHANGED:
+				SwingUtilities.invokeLater( this::update );
+				break;
+			case NUM_SOURCES_CHANGED:
+				SwingUtilities.invokeLater( this::recreateContent );
+				break;
 			}
 		}
 	}
 
-	public static class ModePanel extends JPanel implements VisibilityAndGrouping.UpdateListener
+	public static class ModePanel extends JPanel implements ViewerStateChangeListener
 	{
 		private static final long serialVersionUID = 1L;
 
-		private final VisibilityAndGrouping visibility;
+		private final ViewerState state;
 
 		private JCheckBox groupingBox;
 
 		private JCheckBox fusedModeBox;
 
-		public ModePanel( final VisibilityAndGrouping visibilityAndGrouping )
+		private final BooleanSupplier isVisible;
+
+		public ModePanel( final ViewerState state, BooleanSupplier isVisible )
 		{
 			super( new GridBagLayout() );
-			this.visibility = visibilityAndGrouping;
-			recreateContent();
-			update();
+			this.state = state;
+			this.isVisible = isVisible;
+			state.changeListeners().add( this );
+			synchronized ( state )
+			{
+				recreateContent();
+				update();
+			}
+		}
+
+		private void shown()
+		{
+			if ( updatePending.getAndSet( false ) )
+				updateNow();
 		}
 
-		protected void recreateContent()
+		private void recreateContent()
 		{
 			final GridBagConstraints c = new GridBagConstraints();
 			c.insets = new Insets( 0, 5, 0, 5 );
@@ -321,12 +362,10 @@ public class VisibilityAndGroupingDialog extends JDialog
 			c.gridwidth = 1;
 			c.anchor = GridBagConstraints.LINE_START;
 			groupingBox = new JCheckBox();
-			groupingBox.addActionListener( new ActionListener()
-			{
-				@Override
-				public void actionPerformed( final ActionEvent e )
+			groupingBox.addActionListener( e -> {
+				synchronized ( state )
 				{
-					visibility.setGroupingEnabled( groupingBox.isSelected() );
+					state.setDisplayMode( state.getDisplayMode().withGrouping( groupingBox.isSelected() ) );
 				}
 			} );
 			c.gridx = 0;
@@ -336,12 +375,10 @@ public class VisibilityAndGroupingDialog extends JDialog
 			add( new JLabel("enable grouping"), c );
 
 			fusedModeBox = new JCheckBox();
-			fusedModeBox.addActionListener( new ActionListener()
-			{
-				@Override
-				public void actionPerformed( final ActionEvent e )
+			fusedModeBox.addActionListener( e -> {
+				synchronized ( state )
 				{
-					visibility.setFusedEnabled( fusedModeBox.isSelected() );
+					state.setDisplayMode( state.getDisplayMode().withFused( fusedModeBox.isSelected() ) );
 				}
 			} );
 			c.gridx = 0;
@@ -351,280 +388,334 @@ public class VisibilityAndGroupingDialog extends JDialog
 			add( new JLabel("enable fused mode"), c );
 		}
 
-		protected void update()
+		private final AtomicBoolean updatePending = new AtomicBoolean( true );
+
+		private void update()
 		{
-			synchronized ( visibility )
+			if ( isVisible.getAsBoolean() )
 			{
-				groupingBox.setSelected( visibility.isGroupingEnabled() );
-				fusedModeBox.setSelected( visibility.isFusedEnabled() );
+				updateNow();
+				updatePending.set( false );
 			}
+			else
+				updatePending.set( true );
+		}
+
+		private void updateNow()
+		{
+			groupingBox.setSelected( state.getDisplayMode().hasGrouping() );
+			fusedModeBox.setSelected( state.getDisplayMode().hasFused() );
 		}
 
 		@Override
-		public void visibilityChanged( final Event e )
+		public void viewerStateChanged( final ViewerStateChange change )
 		{
-			synchronized ( visibility )
-			{
-				switch ( e.id )
-				{
-				case DISPLAY_MODE_CHANGED:
-					groupingBox.setSelected( visibility.isGroupingEnabled() );
-					fusedModeBox.setSelected( visibility.isFusedEnabled() );
-					break;
-				}
-			}
+			if ( change == ViewerStateChange.DISPLAY_MODE_CHANGED )
+				SwingUtilities.invokeLater( this::update );
 		}
 	}
 
-	public static class GroupingPanel extends JPanel implements VisibilityAndGrouping.UpdateListener
+	public static class GroupingPanel extends JPanel implements ViewerStateChangeListener
 	{
 		private static final long serialVersionUID = 1L;
 
-		private final VisibilityAndGrouping visibility;
+		private final ArrayList< Runnable > updateNames = new ArrayList<>();
 
-		private final ArrayList< JTextField > nameFields;
+		private final Map< SourceGroup, JRadioButton > currentButtonsMap = new HashMap<>();
 
-		private final ArrayList< JRadioButton > currentButtons;
+		private final ArrayList< Consumer< Set< SourceGroup > > > updateActiveBoxes = new ArrayList<>();
 
-		private final ArrayList< JCheckBox > fusedBoxes;
+		private final ArrayList< Runnable > updateAssignBoxes = new ArrayList<>();
 
-		private final ArrayList< JCheckBox > assignBoxes;
+		private final ViewerState state;
 
-		private int numSources;
+		private final BooleanSupplier isVisible;
 
-		private int numGroups;
-
-		public GroupingPanel( final VisibilityAndGrouping visibilityAndGrouping )
+		public GroupingPanel( final ViewerState state, BooleanSupplier isVisible )
 		{
 			super( new GridBagLayout() );
-			this.visibility = visibilityAndGrouping;
-			nameFields = new ArrayList<>();
-			currentButtons = new ArrayList<>();
-			fusedBoxes = new ArrayList<>();
-			assignBoxes = new ArrayList<>();
-			numSources = visibilityAndGrouping.numSources();
-			numGroups = visibilityAndGrouping.numGroups();
-			recreateContent();
-			update();
+			this.state = state;
+			this.isVisible = isVisible;
+			state.changeListeners().add( this );
 		}
 
-		protected void recreateContent()
+		private void shown()
 		{
-			removeAll();
-			nameFields.clear();
-			currentButtons.clear();
-			fusedBoxes.clear();
-			assignBoxes.clear();
-
-			numSources = visibility.numSources();
-			numGroups = visibility.numGroups();
+			if ( recreateContentPending.getAndSet( false ) )
+				recreateContentNow();
+			if ( updateCurrentGroupPending.getAndSet( false ) )
+				updateCurrentGroupNow();
+			if ( updateGroupNamesPending.getAndSet( false ) )
+				updateGroupNamesNow();
+			if ( updateGroupActivityPending.getAndSet( false ) )
+				updateGroupActivityNow();
+			if ( updateGroupAssignmentsPending.getAndSet( false ) )
+				updateGroupAssignmentsNow();
+		}
 
-			final GridBagConstraints c = new GridBagConstraints();
-			c.insets = new Insets( 0, 5, 0, 5 );
+		private final AtomicBoolean recreateContentPending = new AtomicBoolean( true );
 
-			final List< SourceGroup > groups = visibility.getSourceGroups();
+		private void recreateContent()
+		{
+			if ( isVisible.getAsBoolean() )
+			{
+				recreateContentNow();
+				recreateContentPending.set( false );
+			}
+			else
+				recreateContentPending.set( true );
+		}
 
-			// source shortcuts
-			// TODO: shortcut "names" should not be hard-coded here!
-			c.gridx = 0;
-			c.gridy = 0;
-			add( new JLabel( "shortcut" ), c );
-			c.anchor = GridBagConstraints.LINE_END;
-			c.gridy = GridBagConstraints.RELATIVE;
-			final int nShortcuts = Math.min( numGroups, 10 );
-			for ( int i = 0; i < nShortcuts; ++i )
-				add( new JLabel( Integer.toString( i == 10 ? 0 : i + 1 ) ), c );
-
-			// source names
-			c.gridx = 1;
-			c.gridy = 0;
-			c.anchor = GridBagConstraints.CENTER;
-			add( new JLabel( "group name" ), c );
-			c.anchor = GridBagConstraints.LINE_END;
-			c.gridy = GridBagConstraints.RELATIVE;
-			for ( int g = 0; g < numGroups; ++g )
+		private void recreateContentNow()
+		{
+			synchronized ( state )
 			{
-				final JTextField tf = new JTextField( groups.get( g ).getName(), 10 );
-				final int groupIndex = g;
-				tf.getDocument().addDocumentListener( new DocumentListener()
+				removeAll();
+				updateNames.clear();
+				currentButtonsMap.clear();
+				updateActiveBoxes.clear();
+				updateAssignBoxes.clear();
+
+				final GridBagConstraints c = new GridBagConstraints();
+				c.insets = new Insets( 0, 5, 0, 5 );
+
+				final List< SourceAndConverter< ? > > sources = state.getSources();
+				final List< SourceGroup > groups = state.getGroups();
+
+				// source shortcuts
+				// TODO: shortcut "names" should not be hard-coded here!
+				c.gridx = 0;
+				c.gridy = 0;
+				add( new JLabel( "shortcut" ), c );
+				c.anchor = GridBagConstraints.LINE_END;
+				c.gridy = GridBagConstraints.RELATIVE;
+				final int nShortcuts = Math.min( groups.size(), 10 );
+				for ( int i = 0; i < nShortcuts; ++i )
+					add( new JLabel( Integer.toString( i == 10 ? 0 : i + 1 ) ), c );
+
+				// source names
+				c.gridx = 1;
+				c.gridy = 0;
+				c.anchor = GridBagConstraints.CENTER;
+				add( new JLabel( "group name" ), c );
+				c.anchor = GridBagConstraints.LINE_END;
+				c.gridy = GridBagConstraints.RELATIVE;
+				for ( final SourceGroup group : groups )
 				{
-					private void doit()
+					final JTextField tf = new JTextField( state.getGroupName( group ), 10 );
+					tf.getDocument().addDocumentListener( new DocumentListener()
 					{
-						visibility.setGroupName( groupIndex, tf.getText() );
-					}
+						private void doit()
+						{
+							state.setGroupName( group, tf.getText() );
+						}
 
-					@Override
-					public void removeUpdate( final DocumentEvent e )
-					{
-						doit();
-					}
+						@Override
+						public void removeUpdate( final DocumentEvent e )
+						{
+							doit();
+						}
 
-					@Override
-					public void insertUpdate( final DocumentEvent e )
-					{
-						doit();
-					}
+						@Override
+						public void insertUpdate( final DocumentEvent e )
+						{
+							doit();
+						}
 
-					@Override
-					public void changedUpdate( final DocumentEvent e )
-					{
-						doit();
-					}
-				} );
-				nameFields.add( tf );
-				add( tf, c );
-			}
+						@Override
+						public void changedUpdate( final DocumentEvent e )
+						{
+							doit();
+						}
+					} );
+					updateNames.add( () -> {
+						final String name = state.getGroupName( group );
+						if ( !tf.getText().equals( name ) )
+						{
+							tf.setText( name );
+						}
+					} );
+					add( tf, c );
+				}
 
-			// "current" radio-buttons
-			c.anchor = GridBagConstraints.CENTER;
-			c.gridx = 2;
-			c.gridy = 0;
-			add( new JLabel( "current" ), c );
-			c.gridy = GridBagConstraints.RELATIVE;
-			final ButtonGroup currentButtonGroup = new ButtonGroup();
-			for ( int g = 0; g < numGroups; ++g )
-			{
-				final JRadioButton b = new JRadioButton();
-				final int groupIndex = g;
-				b.addActionListener( new ActionListener()
+				// "current" radio-buttons
+				c.anchor = GridBagConstraints.CENTER;
+				c.gridx = 2;
+				c.gridy = 0;
+				add( new JLabel( "current" ), c );
+				c.gridy = GridBagConstraints.RELATIVE;
+				final ButtonGroup currentButtonGroup = new ButtonGroup();
+				for ( final SourceGroup group : groups )
 				{
-					@Override
-					public void actionPerformed( final ActionEvent e )
-					{
+					final JRadioButton b = new JRadioButton();
+					b.addActionListener( e -> {
 						if ( b.isSelected() )
-							visibility.setCurrentGroup( groupIndex );
-					}
-				} );
-				currentButtons.add( b );
-				currentButtonGroup.add( b );
-				add( b, c );
-			}
+							state.setCurrentGroup( group );
+					} );
+					currentButtonsMap.put( group, b );
+					currentButtonGroup.add( b );
+					add( b, c );
+				}
 
-			// "active in fused" check-boxes
-			c.gridx = 3;
-			c.gridy = 0;
-			c.anchor = GridBagConstraints.CENTER;
-			add( new JLabel( "active in fused" ), c );
-			c.gridy = GridBagConstraints.RELATIVE;
-			for ( int g = 0; g < numGroups; ++g )
-			{
-				final JCheckBox b = new JCheckBox();
-				final int groupIndex = g;
-				b.addActionListener( new ActionListener()
+				// "active in fused" check-boxes
+				c.gridx = 3;
+				c.gridy = 0;
+				c.anchor = GridBagConstraints.CENTER;
+				add( new JLabel( "active in fused" ), c );
+				c.gridy = GridBagConstraints.RELATIVE;
+				for ( final SourceGroup group : groups )
 				{
-					@Override
-					public void actionPerformed( final ActionEvent e )
-					{
-						visibility.setGroupActive( groupIndex, b.isSelected() );
-					}
-				} );
-				fusedBoxes.add( b );
-				add( b, c );
-			}
+					final JCheckBox b = new JCheckBox();
+					b.addActionListener( e -> state.setGroupActive( group, b.isSelected() ) );
+					updateActiveBoxes.add( active -> b.setSelected( active.contains( group ) ) );
+					add( b, c );
+				}
 
-			// setup-to-group assignments
-			c.gridx = 4;
-			c.gridy = 0;
-			c.gridwidth = numSources;
-			c.anchor = GridBagConstraints.CENTER;
-			add( new JLabel( "assigned sources" ), c );
-			c.gridwidth = 1;
-			c.anchor = GridBagConstraints.LINE_END;
-			for ( int s = 0; s < numSources; ++s )
-			{
-				final int sourceIndex = s;
-				c.gridx = sourceIndex + 4;
-				for ( int g = 0; g < numGroups; ++g )
+				// setup-to-group assignments
+				c.gridx = 4;
+				c.gridy = 0;
+				c.gridwidth = sources.size();
+				c.anchor = GridBagConstraints.CENTER;
+				add( new JLabel( "assigned sources" ), c );
+				c.gridwidth = 1;
+				c.anchor = GridBagConstraints.LINE_END;
+				for ( final SourceAndConverter< ? > source : sources )
 				{
-					final int groupIndex = g;
-					c.gridy = g + 1;
-					final JCheckBox b = new JCheckBox();
-					b.addActionListener( new ActionListener()
+					c.gridy = 1;
+					for ( final SourceGroup group : groups )
 					{
-						@Override
-						public void actionPerformed( final ActionEvent e )
-						{
+						final JCheckBox b = new JCheckBox();
+						b.addActionListener( e -> {
 							if ( b.isSelected() )
-								visibility.addSourceToGroup( sourceIndex, groupIndex );
+								state.addSourceToGroup( source, group );
 							else
-								visibility.removeSourceFromGroup( sourceIndex, groupIndex );
-						}
-					} );
-					assignBoxes.add( b );
-					add( b, c );
+								state.removeSourceFromGroup( source, group );
+						} );
+						updateAssignBoxes.add( () -> {
+							b.setSelected( state.getSourcesInGroup( group ).contains( source ) );
+						} );
+						add( b, c );
+						c.gridy++;
+					}
+					c.gridx++;
 				}
+
+				invalidate();
+				final Window frame = SwingUtilities.getWindowAncestor( this );
+				if ( frame != null )
+					frame.pack();
+
+				update();
+			}
+		}
+
+		private void update()
+		{
+			updateGroupNames();
+			updateCurrentGroup();
+			updateGroupActivity();
+			updateGroupAssignments();
+		}
+
+		private final AtomicBoolean updateGroupNamesPending = new AtomicBoolean( true );
+
+		private void updateGroupNames()
+		{
+			if ( isVisible.getAsBoolean() )
+			{
+				updateGroupNamesNow();
+				updateGroupNamesPending.set( false );
 			}
+			else
+				updateGroupNamesPending.set( true );
+		}
 
-			invalidate();
-			final Window frame = SwingUtilities.getWindowAncestor( this );
-			if ( frame != null )
-				frame.pack();
+		private void updateGroupNamesNow()
+		{
+			updateNames.forEach( Runnable::run );
 		}
 
-		protected void update()
+		private final AtomicBoolean updateGroupAssignmentsPending = new AtomicBoolean( true );
+
+		private void updateGroupAssignments()
 		{
-			synchronized ( visibility )
+			if ( isVisible.getAsBoolean() )
 			{
-				if ( visibility.numSources() != numSources || visibility.numGroups() != numGroups )
-					recreateContent();
+				updateGroupAssignmentsNow();
+				updateGroupAssignmentsPending.set( false );
+			}
+			else
+				updateGroupAssignmentsPending.set( true );
+		}
 
-				if ( numGroups > 0  )
-					currentButtons.get( visibility.getCurrentGroup() ).setSelected( true );
+		private void updateGroupAssignmentsNow()
+		{
+			updateAssignBoxes.forEach( Runnable::run );
+		}
 
-				for ( int g = 0; g < numGroups; ++g )
-					fusedBoxes.get( g ).setSelected( visibility.isGroupActive( g ) );
+		private final AtomicBoolean updateGroupActivityPending = new AtomicBoolean( true );
 
-				updateGroupNames();
-				updateGroupAssignments();
+		private void updateGroupActivity()
+		{
+			if ( isVisible.getAsBoolean() )
+			{
+				updateGroupActivityNow();
+				updateGroupActivityPending.set( false );
 			}
+			else
+				updateGroupActivityPending.set( true );
+		}
+
+		private void updateGroupActivityNow()
+		{
+			final Set< SourceGroup > activeGroups = state.getActiveGroups();
+			updateActiveBoxes.forEach( c -> c.accept( activeGroups ) );
 		}
 
-		protected void updateGroupNames()
+		private final AtomicBoolean updateCurrentGroupPending = new AtomicBoolean( true );
+
+		private void updateCurrentGroup()
 		{
-			final List< SourceGroup > groups = visibility.getSourceGroups();
-			for ( int i = 0; i < numGroups; ++i )
+			if ( isVisible.getAsBoolean() )
 			{
-				final JTextField tf = nameFields.get( i );
-				final String name = groups.get( i ).getName();
-				if ( ! tf.getText().equals( name ) )
-					tf.setText( name );
+				updateCurrentGroupNow();
+				updateCurrentGroupPending.set( false );
 			}
+			else
+				updateCurrentGroupPending.set( true );
 		}
 
-		protected void updateGroupAssignments()
+		private void updateCurrentGroupNow()
 		{
-			final List< SourceGroup > groups = visibility.getSourceGroups();
-			for ( int s = 0; s < numSources; ++s )
-				for ( int g = 0; g < numGroups; ++g )
-					assignBoxes.get( s * numGroups + g ).setSelected( groups.get( g ).getSourceIds().contains( s ) );
+			final SourceGroup currentGroup = state.getCurrentGroup();
+			if ( currentGroup == null )
+				currentButtonsMap.values().forEach( b -> b.setSelected( false ) );
+			else
+				currentButtonsMap.get( currentGroup ).setSelected( true );
 		}
 
 		@Override
-		public void visibilityChanged( final Event e )
+		public void viewerStateChanged( final ViewerStateChange change )
 		{
-			synchronized ( visibility )
+			switch( change )
 			{
-				if ( visibility.numSources() != numSources || visibility.numGroups() != numGroups )
-					update();
-
-				switch ( e.id )
-				{
-				case CURRENT_GROUP_CHANGED:
-					currentButtons.get( visibility.getCurrentGroup() ).setSelected( true );
-					break;
-				case GROUP_ACTIVITY_CHANGED:
-					for ( int g = 0; g < numGroups; ++g )
-						fusedBoxes.get( g ).setSelected( visibility.isGroupActive( g ) );
-					break;
-				case SOURCE_TO_GROUP_ASSIGNMENT_CHANGED:
-					updateGroupAssignments();
-					break;
-				case GROUP_NAME_CHANGED:
-					updateGroupNames();
-					break;
-				}
+			case CURRENT_GROUP_CHANGED:
+				SwingUtilities.invokeLater( this::updateCurrentGroup );
+				break;
+			case GROUP_ACTIVITY_CHANGED:
+				SwingUtilities.invokeLater( this::updateGroupActivity );
+				break;
+			case SOURCE_TO_GROUP_ASSIGNMENT_CHANGED:
+				SwingUtilities.invokeLater( this::updateGroupAssignments );
+				break;
+			case GROUP_NAME_CHANGED:
+				SwingUtilities.invokeLater( this::updateGroupNames );
+				break;
+			case NUM_GROUPS_CHANGED:
+			case NUM_SOURCES_CHANGED:
+				SwingUtilities.invokeLater( this::recreateContent );
+				break;
 			}
 		}
 	}
-
 }
diff --git a/src/main/java/bdv/tools/bookmarks/BookmarkTextOverlayAnimator.java b/src/main/java/bdv/tools/bookmarks/BookmarkTextOverlayAnimator.java
index 53824318d77c605cafc2414cd9c0847c07c97537..539290a733ac0536ad87b7303dc15899c6875123 100644
--- a/src/main/java/bdv/tools/bookmarks/BookmarkTextOverlayAnimator.java
+++ b/src/main/java/bdv/tools/bookmarks/BookmarkTextOverlayAnimator.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -44,7 +43,7 @@ import bdv.viewer.animate.OverlayAnimator;
  * Draw one line of text in the center or bottom right of the display. Text is
  * fading in and out.
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
 public class BookmarkTextOverlayAnimator implements OverlayAnimator
 {
diff --git a/src/main/java/bdv/tools/bookmarks/Bookmarks.java b/src/main/java/bdv/tools/bookmarks/Bookmarks.java
index 45e197354479544ba6b15dbcbf14024df84bbfb8..13da5c87fb5c1b78bbe69895f0eddaf2a988d527 100644
--- a/src/main/java/bdv/tools/bookmarks/Bookmarks.java
+++ b/src/main/java/bdv/tools/bookmarks/Bookmarks.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/tools/bookmarks/BookmarksEditor.java b/src/main/java/bdv/tools/bookmarks/BookmarksEditor.java
index 577ecec07ca9f2bd690b4065778a9db7cb9ad31e..59a8b5539ce29483476845686b299f50998694a1 100644
--- a/src/main/java/bdv/tools/bookmarks/BookmarksEditor.java
+++ b/src/main/java/bdv/tools/bookmarks/BookmarksEditor.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -117,7 +116,7 @@ public class BookmarksEditor
 						case SET:
 						{
 							final AffineTransform3D t = new AffineTransform3D();
-							viewer.getState().getViewerTransform( t );
+							viewer.state().getViewerTransform( t );
 							final double cX = viewer.getDisplay().getWidth() / 2.0;
 							final double cY = viewer.getDisplay().getHeight() / 2.0;
 							t.set( t.get( 0, 3 ) - cX, 0, 3 );
@@ -133,7 +132,7 @@ public class BookmarksEditor
 							if ( t != null )
 							{
 								final AffineTransform3D c = new AffineTransform3D();
-								viewer.getState().getViewerTransform( c );
+								viewer.state().getViewerTransform( c );
 								final double cX = viewer.getDisplay().getWidth() / 2.0;
 								final double cY = viewer.getDisplay().getHeight() / 2.0;
 								c.set( c.get( 0, 3 ) - cX, 0, 3 );
@@ -149,7 +148,7 @@ public class BookmarksEditor
 							if ( t != null )
 							{
 								final AffineTransform3D c = new AffineTransform3D();
-								viewer.getState().getViewerTransform( c );
+								viewer.state().getViewerTransform( c );
 								final Point p = new Point( 2 );
 								viewer.getMouseCoordinates( p );
 								final double[] qTarget = new double[ 4 ];
diff --git a/src/main/java/bdv/tools/boundingbox/AbstractTransformedBoxModel.java b/src/main/java/bdv/tools/boundingbox/AbstractTransformedBoxModel.java
index 9bdbdb19902999843f3bba70a9970bffbb42a14f..e1e807c3c29a6b47a2a635ef8cf7a06f34a78909 100644
--- a/src/main/java/bdv/tools/boundingbox/AbstractTransformedBoxModel.java
+++ b/src/main/java/bdv/tools/boundingbox/AbstractTransformedBoxModel.java
@@ -1,19 +1,18 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
- *
+ * 
  * 1. Redistributions of source code must retain the above copyright notice,
  *    this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
  *    this list of conditions and the following disclaimer in the documentation
  *    and/or other materials provided with the distribution.
- *
+ * 
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
diff --git a/src/main/java/bdv/tools/boundingbox/AbstractTransformedBoxSelectionDialog.java b/src/main/java/bdv/tools/boundingbox/AbstractTransformedBoxSelectionDialog.java
index 0f782d7381996bee1cf815a0e38844cfccef65ff..4840454f73f954de600e525a076db04f7d8e08c7 100644
--- a/src/main/java/bdv/tools/boundingbox/AbstractTransformedBoxSelectionDialog.java
+++ b/src/main/java/bdv/tools/boundingbox/AbstractTransformedBoxSelectionDialog.java
@@ -1,19 +1,18 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
- *
+ * 
  * 1. Redistributions of source code must retain the above copyright notice,
  *    this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
  *    this list of conditions and the following disclaimer in the documentation
  *    and/or other materials provided with the distribution.
- *
+ * 
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
@@ -44,6 +43,7 @@ import javax.swing.BoxLayout;
 import javax.swing.JButton;
 import javax.swing.JDialog;
 import javax.swing.JPanel;
+import javax.swing.SwingUtilities;
 import javax.swing.WindowConstants;
 
 import bdv.tools.boundingbox.BoxSelectionOptions.TimepointSelection;
@@ -118,7 +118,7 @@ public abstract class AbstractTransformedBoxSelectionDialog< R > extends JDialog
 		synchronized ( monitor )
 		{
 			result = null;
-			setVisible( true );
+			SwingUtilities.invokeLater( () -> setVisible( true ) );
 			while( true )
 			{
 				try
@@ -160,7 +160,7 @@ public abstract class AbstractTransformedBoxSelectionDialog< R > extends JDialog
 			mode = options.values.getTimepointSelection();
 			if ( mode == SINGLE || mode == RANGE )
 			{
-				final int tmaxViewer = viewer.getState().getNumTimepoints() - 1;
+				final int tmaxViewer = viewer.state().getNumTimepoints() - 1;
 				final int tmin = Math.max( 0, Math.min( tmaxViewer, options.values.getRangeMinTimepoint() ) );
 				final int tmax = Math.max( tmin, Math.min( tmaxViewer, options.values.getRangeMaxTimepoint() ) );;
 
diff --git a/src/main/java/bdv/tools/boundingbox/BoundingBoxDialog.java b/src/main/java/bdv/tools/boundingbox/BoundingBoxDialog.java
index 3243cd5cdcc20041f72ddc0361ab9abaf8b689bd..52002dbe20e47434401d8ce7812f984b5524ddc9 100644
--- a/src/main/java/bdv/tools/boundingbox/BoundingBoxDialog.java
+++ b/src/main/java/bdv/tools/boundingbox/BoundingBoxDialog.java
@@ -1,19 +1,18 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
- *
+ * 
  * 1. Redistributions of source code must retain the above copyright notice,
  *    this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
  *    this list of conditions and the following disclaimer in the documentation
  *    and/or other materials provided with the distribution.
- *
+ * 
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
@@ -52,6 +51,7 @@ import net.imglib2.type.numeric.ARGBType;
 import net.imglib2.type.numeric.integer.UnsignedShortType;
 
 import bdv.tools.boundingbox.BoxSelectionPanel.Box;
+import bdv.tools.brightness.ConverterSetup.SetupChangeListener;
 import bdv.tools.brightness.RealARGBColorConverterSetup;
 import bdv.tools.brightness.SetupAssignments;
 import bdv.tools.transformation.TransformedSource;
@@ -134,7 +134,8 @@ public class BoundingBoxDialog extends JDialog
 
 		// create a ConverterSetup (can be used by the brightness dialog to adjust the converter settings)
 		boxConverterSetup = new RealARGBColorConverterSetup( boxSetupId, converter );
-		boxConverterSetup.setViewer( viewer );
+		final SetupChangeListener requestRepaint = s -> viewer.requestRepaint();
+		boxConverterSetup.setupChangeListeners().add( requestRepaint );
 
 		// create a SourceAndConverter (can be added to the viewer for display)
 		final TransformedSource< UnsignedShortType > ts = new TransformedSource<>( boxSource );
@@ -186,7 +187,7 @@ public class BoundingBoxDialog extends JDialog
 				{
 					viewer.addSource( boxSourceAndConverter );
 					setupAssignments.addSetup( boxConverterSetup );
-					boxConverterSetup.setViewer( viewer );
+					boxConverterSetup.setupChangeListeners().add( requestRepaint );
 
 					final int bbSourceIndex = viewer.getState().numSources() - 1;
 					final VisibilityAndGrouping vg = viewer.getVisibilityAndGrouping();
@@ -201,8 +202,8 @@ public class BoundingBoxDialog extends JDialog
 				}
 				if ( showBoxOverlay )
 				{
-					viewer.getDisplay().addOverlayRenderer( boxOverlay );
-					viewer.addRenderTransformListener( boxOverlay );
+					viewer.getDisplay().overlays().add( boxOverlay );
+					viewer.renderTransformListeners().add( boxOverlay );
 				}
 			}
 
@@ -213,10 +214,11 @@ public class BoundingBoxDialog extends JDialog
 				{
 					viewer.removeSource( boxSourceAndConverter.getSpimSource() );
 					setupAssignments.removeSetup( boxConverterSetup );
+					boxConverterSetup.setupChangeListeners().remove( requestRepaint );
 				}
 				if ( showBoxOverlay )
 				{
-					viewer.getDisplay().removeOverlayRenderer( boxOverlay );
+					viewer.getDisplay().overlays().remove( boxOverlay );
 					viewer.removeTransformListener( boxOverlay );
 				}
 			}
diff --git a/src/main/java/bdv/tools/boundingbox/BoundingBoxUtil.java b/src/main/java/bdv/tools/boundingbox/BoundingBoxUtil.java
index 62b60894b6f9aac8f5bba98dba98d31dfddb5872..8f808dce7f371a6311053d451e8f2284db8164da 100644
--- a/src/main/java/bdv/tools/boundingbox/BoundingBoxUtil.java
+++ b/src/main/java/bdv/tools/boundingbox/BoundingBoxUtil.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -29,6 +28,7 @@
  */
 package bdv.tools.boundingbox;
 
+import bdv.viewer.SourceAndConverter;
 import java.util.ArrayList;
 import java.util.Collection;
 
@@ -40,7 +40,7 @@ import net.imglib2.RealPoint;
 import net.imglib2.realtransform.AffineTransform3D;
 import bdv.viewer.Source;
 import bdv.viewer.state.SourceState;
-import bdv.viewer.state.ViewerState;
+import bdv.viewer.ViewerState;
 
 public class BoundingBoxUtil
 {
@@ -52,7 +52,7 @@ public class BoundingBoxUtil
 	public static Interval getSourcesBoundingBox( final ViewerState state, final int minTimepointIndex, final int maxTimepointIndex )
 	{
 		final ArrayList< Source< ? > > sources = new ArrayList<>();
-		for ( final SourceState< ? > source : state.getSources() )
+		for ( final SourceAndConverter< ? > source : state.getSources() )
 			sources.add( source.getSpimSource() );
 		return getSourcesBoundingBox( sources, minTimepointIndex, maxTimepointIndex );
 	}
@@ -107,7 +107,7 @@ public class BoundingBoxUtil
 	public static RealInterval getSourcesBoundingBoxReal( final ViewerState state, final int minTimepointIndex, final int maxTimepointIndex )
 	{
 		final ArrayList< Source< ? > > sources = new ArrayList<>();
-		for ( final SourceState< ? > source : state.getSources() )
+		for ( final SourceAndConverter< ? > source : state.getSources() )
 			sources.add( source.getSpimSource() );
 		return getSourcesBoundingBoxReal( sources, minTimepointIndex, maxTimepointIndex );
 	}
@@ -185,4 +185,28 @@ public class BoundingBoxUtil
 		}
 		return new FinalRealInterval( bbMin, bbMax );
 	}
+
+	@Deprecated
+	public static Interval getSourcesBoundingBox( final bdv.viewer.state.ViewerState state )
+	{
+		return getSourcesBoundingBox( state.getState() );
+	}
+
+	@Deprecated
+	public static Interval getSourcesBoundingBox( final bdv.viewer.state.ViewerState state, final int minTimepointIndex, final int maxTimepointIndex )
+	{
+		return getSourcesBoundingBox( state.getState(), minTimepointIndex, maxTimepointIndex );
+	}
+
+	@Deprecated
+	public static RealInterval getSourcesBoundingBoxReal( final bdv.viewer.state.ViewerState state )
+	{
+		return getSourcesBoundingBoxReal( state.getState() );
+	}
+
+	@Deprecated
+	public static RealInterval getSourcesBoundingBoxReal( final bdv.viewer.state.ViewerState state, final int minTimepointIndex, final int maxTimepointIndex )
+	{
+		return getSourcesBoundingBoxReal( state.getState(), minTimepointIndex, maxTimepointIndex );
+	}
 }
diff --git a/src/main/java/bdv/tools/boundingbox/BoxDisplayModePanel.java b/src/main/java/bdv/tools/boundingbox/BoxDisplayModePanel.java
index c912169dc066a5e46ac673ba1226f09e0b489cc0..f8715e21d969a0602915794b3d31e3b3ff207733 100644
--- a/src/main/java/bdv/tools/boundingbox/BoxDisplayModePanel.java
+++ b/src/main/java/bdv/tools/boundingbox/BoxDisplayModePanel.java
@@ -1,19 +1,18 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
- *
+ * 
  * 1. Redistributions of source code must retain the above copyright notice,
  *    this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
  *    this list of conditions and the following disclaimer in the documentation
  *    and/or other materials provided with the distribution.
- *
+ * 
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
diff --git a/src/main/java/bdv/tools/boundingbox/BoxRealRandomAccessible.java b/src/main/java/bdv/tools/boundingbox/BoxRealRandomAccessible.java
index 9c248b4cc3bed02ff90dd21b409ec23c57a56508..f1aa7aaa355f747d58af0f5d98bc791e0873d4fe 100644
--- a/src/main/java/bdv/tools/boundingbox/BoxRealRandomAccessible.java
+++ b/src/main/java/bdv/tools/boundingbox/BoxRealRandomAccessible.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/tools/boundingbox/BoxSelectionOptions.java b/src/main/java/bdv/tools/boundingbox/BoxSelectionOptions.java
index b6486543091181ea9f4a86b82d9a5b1c8f00e815..0dc0edc20f43f872be9f61f22f3573a97288856f 100644
--- a/src/main/java/bdv/tools/boundingbox/BoxSelectionOptions.java
+++ b/src/main/java/bdv/tools/boundingbox/BoxSelectionOptions.java
@@ -1,19 +1,18 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
- *
+ * 
  * 1. Redistributions of source code must retain the above copyright notice,
  *    this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
  *    this list of conditions and the following disclaimer in the documentation
  *    and/or other materials provided with the distribution.
- *
+ * 
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
diff --git a/src/main/java/bdv/tools/boundingbox/BoxSelectionPanel.java b/src/main/java/bdv/tools/boundingbox/BoxSelectionPanel.java
index f45628c0b85ca68d068cc42494c704773fbb5896..ff70e0e411830609e7c86b238e24587a67506155 100644
--- a/src/main/java/bdv/tools/boundingbox/BoxSelectionPanel.java
+++ b/src/main/java/bdv/tools/boundingbox/BoxSelectionPanel.java
@@ -1,19 +1,18 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
- *
+ * 
  * 1. Redistributions of source code must retain the above copyright notice,
  *    this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
  *    this list of conditions and the following disclaimer in the documentation
  *    and/or other materials provided with the distribution.
- *
+ * 
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
diff --git a/src/main/java/bdv/tools/boundingbox/DragBoxCornerBehaviour.java b/src/main/java/bdv/tools/boundingbox/DragBoxCornerBehaviour.java
index 713d2ab3b0e5fedd5bdd447a3bf3788a2f8aad80..edba768256acdcd08c755b7da4fd1a131dfae456 100644
--- a/src/main/java/bdv/tools/boundingbox/DragBoxCornerBehaviour.java
+++ b/src/main/java/bdv/tools/boundingbox/DragBoxCornerBehaviour.java
@@ -1,19 +1,18 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
- *
+ * 
  * 1. Redistributions of source code must retain the above copyright notice,
  *    this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
  *    this list of conditions and the following disclaimer in the documentation
  *    and/or other materials provided with the distribution.
- *
+ * 
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
diff --git a/src/main/java/bdv/tools/boundingbox/IntervalCorners.java b/src/main/java/bdv/tools/boundingbox/IntervalCorners.java
index 269c8aeb92c1a09552fe9bff7c46f1c13d528e8c..5a2990078aea578d82cfc3846650e252d5cc499b 100644
--- a/src/main/java/bdv/tools/boundingbox/IntervalCorners.java
+++ b/src/main/java/bdv/tools/boundingbox/IntervalCorners.java
@@ -1,19 +1,18 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
- *
+ * 
  * 1. Redistributions of source code must retain the above copyright notice,
  *    this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
  *    this list of conditions and the following disclaimer in the documentation
  *    and/or other materials provided with the distribution.
- *
+ * 
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
diff --git a/src/main/java/bdv/tools/boundingbox/RealBoxSelectionPanel.java b/src/main/java/bdv/tools/boundingbox/RealBoxSelectionPanel.java
index a3b298dba7f6d1f7f828cc2997934f3b06de630a..9c5bce63024ef74e73bcbc88a5c4ff1721313eba 100644
--- a/src/main/java/bdv/tools/boundingbox/RealBoxSelectionPanel.java
+++ b/src/main/java/bdv/tools/boundingbox/RealBoxSelectionPanel.java
@@ -1,19 +1,18 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
- *
+ * 
  * 1. Redistributions of source code must retain the above copyright notice,
  *    this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
  *    this list of conditions and the following disclaimer in the documentation
  *    and/or other materials provided with the distribution.
- *
+ * 
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
diff --git a/src/main/java/bdv/tools/boundingbox/RenderBoxHelper.java b/src/main/java/bdv/tools/boundingbox/RenderBoxHelper.java
index 4871c60e8b1118da7873378cc5a0e8084536ed8e..8fb8aa2eacad57537be9c45439a25e16c860be13 100644
--- a/src/main/java/bdv/tools/boundingbox/RenderBoxHelper.java
+++ b/src/main/java/bdv/tools/boundingbox/RenderBoxHelper.java
@@ -1,19 +1,18 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
- *
+ * 
  * 1. Redistributions of source code must retain the above copyright notice,
  *    this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
  *    this list of conditions and the following disclaimer in the documentation
  *    and/or other materials provided with the distribution.
- *
+ * 
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
diff --git a/src/main/java/bdv/tools/boundingbox/TransformedBox.java b/src/main/java/bdv/tools/boundingbox/TransformedBox.java
index 75e1a145166e4705ef807061c39b3c5e4e505557..a7dbdf503b4cbc92007b3229014d99ba60883c22 100644
--- a/src/main/java/bdv/tools/boundingbox/TransformedBox.java
+++ b/src/main/java/bdv/tools/boundingbox/TransformedBox.java
@@ -1,3 +1,31 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
 package bdv.tools.boundingbox;
 
 import net.imglib2.RealInterval;
diff --git a/src/main/java/bdv/tools/boundingbox/TransformedBoxEditor.java b/src/main/java/bdv/tools/boundingbox/TransformedBoxEditor.java
index 6380939cd2e205782c9b978239097b56ad335d85..0dcc495436eb7f00bbde294d0ff1e84565ac02dc 100644
--- a/src/main/java/bdv/tools/boundingbox/TransformedBoxEditor.java
+++ b/src/main/java/bdv/tools/boundingbox/TransformedBoxEditor.java
@@ -1,19 +1,18 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
- *
+ * 
  * 1. Redistributions of source code must retain the above copyright notice,
  *    this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
  *    this list of conditions and the following disclaimer in the documentation
  *    and/or other materials provided with the distribution.
- *
+ * 
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
@@ -31,6 +30,7 @@ package bdv.tools.boundingbox;
 
 import static bdv.tools.boundingbox.TransformedBoxOverlay.BoxDisplayMode.FULL;
 
+import bdv.viewer.ConverterSetups;
 import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
@@ -45,7 +45,6 @@ import org.scijava.ui.behaviour.util.Behaviours;
 import org.scijava.ui.behaviour.util.TriggerBehaviourBindings;
 
 import bdv.tools.boundingbox.TransformedBoxOverlay.BoxDisplayMode;
-import bdv.tools.brightness.SetupAssignments;
 import bdv.viewer.ViewerPanel;
 
 /**
@@ -94,17 +93,19 @@ public class TransformedBoxEditor
 	public TransformedBoxEditor(
 			final InputTriggerConfig keyconf,
 			final ViewerPanel viewer,
-			final SetupAssignments setupAssignments,
+			final ConverterSetups converterSetups,
+			final int setupId,
 			final TriggerBehaviourBindings triggerbindings,
 			final AbstractTransformedBoxModel model )
 	{
-		this( keyconf, viewer, setupAssignments, triggerbindings, model, "selection", BoxSourceType.PLACEHOLDER );
+		this( keyconf, viewer, converterSetups, setupId, triggerbindings, model, "selection", BoxSourceType.PLACEHOLDER );
 	}
 
 	public TransformedBoxEditor(
 			final InputTriggerConfig keyconf,
 			final ViewerPanel viewer,
-			final SetupAssignments setupAssignments,
+			final ConverterSetups converterSetups,
+			final int setupId,
 			final TriggerBehaviourBindings triggerbindings,
 			final AbstractTransformedBoxModel model,
 			final String boxSourceName,
@@ -129,7 +130,7 @@ public class TransformedBoxEditor
 		switch ( boxSourceType )
 		{
 		case PLACEHOLDER:
-			boxSource = new TransformedBoxOverlaySource( boxSourceName, boxOverlay, model, viewer, setupAssignments );
+			boxSource = new TransformedBoxOverlaySource( boxSourceName, boxOverlay, model, viewer, converterSetups, setupId );
 			break;
 		case NONE:
 		default:
@@ -154,8 +155,8 @@ public class TransformedBoxEditor
 
 	public void install()
 	{
-		viewer.getDisplay().addOverlayRenderer( boxOverlay );
-		viewer.addRenderTransformListener( boxOverlay );
+		viewer.getDisplay().overlays().add( boxOverlay );
+		viewer.renderTransformListeners().add( boxOverlay );
 		viewer.getDisplay().addHandler( boxOverlay.getCornerHighlighter() );
 
 		refreshBlockMap();
@@ -167,7 +168,7 @@ public class TransformedBoxEditor
 
 	public void uninstall()
 	{
-		viewer.getDisplay().removeOverlayRenderer( boxOverlay );
+		viewer.getDisplay().overlays().remove( boxOverlay );
 		viewer.removeTransformListener( boxOverlay );
 		viewer.getDisplay().removeHandler( boxOverlay.getCornerHighlighter() );
 
diff --git a/src/main/java/bdv/tools/boundingbox/TransformedBoxModel.java b/src/main/java/bdv/tools/boundingbox/TransformedBoxModel.java
index c1e110b820fed0bfd3fa72a14085e8749d8f6bab..9043f072c30e9119cf3ee6460e29d68401c69fa9 100644
--- a/src/main/java/bdv/tools/boundingbox/TransformedBoxModel.java
+++ b/src/main/java/bdv/tools/boundingbox/TransformedBoxModel.java
@@ -1,3 +1,31 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
 package bdv.tools.boundingbox;
 
 import bdv.util.ModifiableInterval;
diff --git a/src/main/java/bdv/tools/boundingbox/TransformedBoxOverlay.java b/src/main/java/bdv/tools/boundingbox/TransformedBoxOverlay.java
index bae13dcd90043d8073231dd3714c6aeaebfd4913..7439703cad1b91241acfb288388da9e8d0c20e4c 100644
--- a/src/main/java/bdv/tools/boundingbox/TransformedBoxOverlay.java
+++ b/src/main/java/bdv/tools/boundingbox/TransformedBoxOverlay.java
@@ -1,19 +1,18 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
- *
+ * 
  * 1. Redistributions of source code must retain the above copyright notice,
  *    this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
  *    this list of conditions and the following disclaimer in the documentation
  *    and/or other materials provided with the distribution.
- *
+ * 
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
@@ -46,8 +45,8 @@ import java.awt.geom.GeneralPath;
 import net.imglib2.Interval;
 import net.imglib2.RealInterval;
 import net.imglib2.realtransform.AffineTransform3D;
-import net.imglib2.ui.OverlayRenderer;
-import net.imglib2.ui.TransformListener;
+import bdv.viewer.OverlayRenderer;
+import bdv.viewer.TransformListener;
 
 import org.scijava.listeners.ChangeListener;
 import org.scijava.listeners.ListenableVar;
diff --git a/src/main/java/bdv/tools/boundingbox/TransformedBoxOverlaySource.java b/src/main/java/bdv/tools/boundingbox/TransformedBoxOverlaySource.java
index 699fbe783aa320ceb60923164eb96cb21b9ad5dd..a5209e9cbc5478a686a87cb58bd9ca30da558313 100644
--- a/src/main/java/bdv/tools/boundingbox/TransformedBoxOverlaySource.java
+++ b/src/main/java/bdv/tools/boundingbox/TransformedBoxOverlaySource.java
@@ -1,19 +1,18 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
- *
+ * 
  * 1. Redistributions of source code must retain the above copyright notice,
  *    this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
  *    this list of conditions and the following disclaimer in the documentation
  *    and/or other materials provided with the distribution.
- *
+ * 
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
@@ -29,22 +28,19 @@
  */
 package bdv.tools.boundingbox;
 
-import bdv.tools.brightness.SetupAssignments;
+import bdv.util.Bounds;
 import bdv.util.PlaceHolderConverterSetup;
+import bdv.viewer.ConverterSetups;
 import bdv.viewer.DisplayMode;
-import bdv.viewer.Source;
 import bdv.viewer.SourceAndConverter;
 import bdv.viewer.ViewerPanel;
-import bdv.viewer.VisibilityAndGrouping;
-import bdv.viewer.VisibilityAndGrouping.Event;
-import bdv.viewer.state.SourceGroup;
-import bdv.viewer.state.SourceState;
-import bdv.viewer.state.ViewerState;
+import bdv.viewer.ViewerState;
+import bdv.viewer.ViewerStateChange;
+import bdv.viewer.ViewerStateChangeListener;
 import java.awt.Color;
 import net.imglib2.type.numeric.ARGBType;
 
-import static bdv.viewer.VisibilityAndGrouping.Event.SOURCE_ACTVITY_CHANGED;
-import static bdv.viewer.VisibilityAndGrouping.Event.VISIBILITY_CHANGED;
+import static bdv.viewer.ViewerStateChange.VISIBILITY_CHANGED;
 
 /**
  * A BDV source (and converter etc) representing a {@code TransformedBox}.
@@ -61,13 +57,11 @@ public class TransformedBoxOverlaySource
 
 	private final PlaceHolderConverterSetup boxConverterSetup;
 
-	private final Source< Void > boxSource;
-
 	private final SourceAndConverter< Void > boxSourceAndConverter;
 
 	private final ViewerPanel viewer;
 
-	private final SetupAssignments setupAssignments;
+	private final ConverterSetups setups;
 
 	private boolean isVisible;
 
@@ -76,85 +70,61 @@ public class TransformedBoxOverlaySource
 			final TransformedBoxOverlay boxOverlay,
 			final TransformedBox bbSource,
 			final ViewerPanel viewer,
-			final SetupAssignments setupAssignments )
+			final ConverterSetups converterSetups,
+			final int setupId )
 	{
 		this.boxOverlay = boxOverlay;
 		this.viewer = viewer;
-		this.setupAssignments = setupAssignments;
+		this.setups = converterSetups;
 
-		final int setupId = SetupAssignments.getUnusedSetupId( setupAssignments );
 		boxConverterSetup = new PlaceHolderConverterSetup( setupId, 0, 128, new ARGBType( 0x00994499) );
 
-		boxConverterSetup.setViewer( this::repaint );
-		boxSource = new TransformedBoxPlaceHolderSource( name, bbSource );
-		boxSourceAndConverter = new SourceAndConverter<>( boxSource, ( input, output ) -> output.set( 0 ) );
+		boxConverterSetup.setupChangeListeners().add( s -> this.repaint() );
+		boxSourceAndConverter = new SourceAndConverter<>(
+				new TransformedBoxPlaceHolderSource( name, bbSource ),
+				( input, output ) -> output.set( 0 ) );
 	}
 
 	public void addToViewer()
 	{
-		final VisibilityAndGrouping vg = viewer.getVisibilityAndGrouping();
-		if ( vg.getDisplayMode() != DisplayMode.FUSED )
+		final ViewerState state = viewer.state();
+		synchronized ( state )
 		{
-			final int numSources = vg.numSources();
-			for ( int i = 0; i < numSources; ++i )
-				vg.setSourceActive( i, vg.isSourceVisible( i ) );
-			vg.setDisplayMode( DisplayMode.FUSED );
+			if ( state.getDisplayMode() != DisplayMode.FUSED )
+			{
+				for ( SourceAndConverter< ? > source : state.getSources() )
+					state.setSourceActive( source, state.isSourceVisible( source ) );
+				state.setDisplayMode( DisplayMode.FUSED );
+			}
+
+			state.addSource( boxSourceAndConverter );
+			state.changeListeners().add( viewerStateChangeListener );
+			state.setSourceActive( boxSourceAndConverter, true );
+			state.setCurrentSource( boxSourceAndConverter );
+
+			isVisible = state.isSourceVisible( boxSourceAndConverter );
 		}
 
-		viewer.addSource( boxSourceAndConverter );
-		vg.addUpdateListener( visibilityChanged );
-		vg.setSourceActive( boxSource, true );
-		vg.setCurrentSource( boxSource );
+		setups.put( boxSourceAndConverter, boxConverterSetup );
+		setups.getBounds().setBounds( boxConverterSetup, new Bounds( 0, 255 ) );
 
-		setupAssignments.addSetup( boxConverterSetup );
-		setupAssignments.getMinMaxGroup( boxConverterSetup ).setRange( 0, 255 );
-
-		isVisible = isVisible();
 		repaint();
 	}
 
 	public void removeFromViewer()
 	{
-		final VisibilityAndGrouping vg = viewer.getVisibilityAndGrouping();
-		vg.removeUpdateListener( visibilityChanged );
-		viewer.removeSource( boxSource );
-		setupAssignments.removeSetup( boxConverterSetup );
-	}
-
-	private boolean isVisible()
-	{
-		final ViewerState state = viewer.getState();
-		int sourceIndex = 0;
-		for ( final SourceState< ? > s : state.getSources() )
-			if ( s.getSpimSource() == boxSource )
-				break;
-			else
-				++sourceIndex;
-		switch ( state.getDisplayMode() )
-		{
-		case SINGLE:
-			return ( sourceIndex == state.getCurrentSource() );
-		case GROUP:
-			return state.getSourceGroups().get( state.getCurrentGroup() ).getSourceIds().contains( sourceIndex );
-		case FUSED:
-			return state.getSources().get( sourceIndex ).isActive();
-		case FUSEDGROUP:
-		default:
-			for ( final SourceGroup group : state.getSourceGroups() )
-				if ( group.isActive() && group.getSourceIds().contains( sourceIndex ) )
-					return true;
-		}
-		return false;
+		viewer.state().changeListeners().remove( viewerStateChangeListener );
+		viewer.state().removeSource( boxSourceAndConverter );
 	}
 
-	private final VisibilityAndGrouping.UpdateListener visibilityChanged = this::visibilityChanged;
+	private final ViewerStateChangeListener viewerStateChangeListener = this::viewerStateChanged;
 
-	private void visibilityChanged( final Event e )
+	private void viewerStateChanged( final ViewerStateChange change )
 	{
-		if ( e.id == VISIBILITY_CHANGED || e.id == SOURCE_ACTVITY_CHANGED )
+		if ( change == VISIBILITY_CHANGED )
 		{
 			final boolean wasVisible = isVisible;
-			isVisible = isVisible();
+			isVisible = viewer.state().isSourceVisible( boxSourceAndConverter );
 			if ( wasVisible != isVisible )
 				repaint();
 		}
@@ -162,6 +132,7 @@ public class TransformedBoxOverlaySource
 
 	private void repaint()
 	{
+		System.out.println( "TransformedBoxOverlaySource.repaint" );
 		boxOverlay.fillIntersection( isVisible );
 		if ( isVisible )
 		{
diff --git a/src/main/java/bdv/tools/boundingbox/TransformedBoxPlaceHolderSource.java b/src/main/java/bdv/tools/boundingbox/TransformedBoxPlaceHolderSource.java
index abdd3ba701e60584634816a8ce66d8333ae069e0..fb7c43086f836bd0ce308f4f63acd48d2c9af851 100644
--- a/src/main/java/bdv/tools/boundingbox/TransformedBoxPlaceHolderSource.java
+++ b/src/main/java/bdv/tools/boundingbox/TransformedBoxPlaceHolderSource.java
@@ -1,19 +1,18 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
- *
+ * 
  * 1. Redistributions of source code must retain the above copyright notice,
  *    this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
  *    this list of conditions and the following disclaimer in the documentation
  *    and/or other materials provided with the distribution.
- *
+ * 
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
diff --git a/src/main/java/bdv/tools/boundingbox/TransformedBoxSelectionDialog.java b/src/main/java/bdv/tools/boundingbox/TransformedBoxSelectionDialog.java
index cee31ad5a0d848710cb6d125cce508d0294b4c99..f170378c8a3e613fe419396643e78f2eac56045f 100644
--- a/src/main/java/bdv/tools/boundingbox/TransformedBoxSelectionDialog.java
+++ b/src/main/java/bdv/tools/boundingbox/TransformedBoxSelectionDialog.java
@@ -1,19 +1,18 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
- *
+ * 
  * 1. Redistributions of source code must retain the above copyright notice,
  *    this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
  *    this list of conditions and the following disclaimer in the documentation
  *    and/or other materials provided with the distribution.
- *
+ * 
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
@@ -31,6 +30,7 @@ package bdv.tools.boundingbox;
 
 import static bdv.tools.boundingbox.BoxSelectionOptions.TimepointSelection.NONE;
 
+import bdv.viewer.ConverterSetups;
 import java.awt.BorderLayout;
 import java.awt.Font;
 import java.awt.GridBagConstraints;
@@ -52,7 +52,6 @@ import net.imglib2.realtransform.AffineTransform3D;
 import org.scijava.ui.behaviour.io.InputTriggerConfig;
 import org.scijava.ui.behaviour.util.TriggerBehaviourBindings;
 
-import bdv.tools.brightness.SetupAssignments;
 import bdv.viewer.ViewerPanel;
 
 /**
@@ -79,7 +78,8 @@ public class TransformedBoxSelectionDialog extends AbstractTransformedBoxSelecti
 
 	public TransformedBoxSelectionDialog(
 			final ViewerPanel viewer,
-			final SetupAssignments setupAssignments,
+			final ConverterSetups converterSetups,
+			final int setupId,
 			final InputTriggerConfig keyConfig,
 			final TriggerBehaviourBindings triggerbindings,
 			final AffineTransform3D boxTransform,
@@ -106,7 +106,8 @@ public class TransformedBoxSelectionDialog extends AbstractTransformedBoxSelecti
 		boxEditor = new TransformedBoxEditor(
 				keyConfig,
 				viewer,
-				setupAssignments,
+				converterSetups,
+				setupId,
 				triggerbindings,
 				model );
 		boxEditor.setPerspective( 1, 1000 ); // TODO expose, initialize from rangeInterval
diff --git a/src/main/java/bdv/tools/boundingbox/TransformedRealBoxModel.java b/src/main/java/bdv/tools/boundingbox/TransformedRealBoxModel.java
index 0c42a47f5e440796a73d524863c980736b84ef44..18deaea54b0c4d2e68331829bab9546676373933 100644
--- a/src/main/java/bdv/tools/boundingbox/TransformedRealBoxModel.java
+++ b/src/main/java/bdv/tools/boundingbox/TransformedRealBoxModel.java
@@ -1,3 +1,31 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
 package bdv.tools.boundingbox;
 
 import bdv.util.ModifiableRealInterval;
diff --git a/src/main/java/bdv/tools/boundingbox/TransformedRealBoxSelectionDialog.java b/src/main/java/bdv/tools/boundingbox/TransformedRealBoxSelectionDialog.java
index b4c707de59c9aeb457f9d639d37cb6463a2f4011..7bfc7e765b7c06d3ce91a6977f4170df905660c6 100644
--- a/src/main/java/bdv/tools/boundingbox/TransformedRealBoxSelectionDialog.java
+++ b/src/main/java/bdv/tools/boundingbox/TransformedRealBoxSelectionDialog.java
@@ -1,19 +1,18 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
- *
+ * 
  * 1. Redistributions of source code must retain the above copyright notice,
  *    this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
  *    this list of conditions and the following disclaimer in the documentation
  *    and/or other materials provided with the distribution.
- *
+ * 
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
@@ -31,6 +30,7 @@ package bdv.tools.boundingbox;
 
 import static bdv.tools.boundingbox.BoxSelectionOptions.TimepointSelection.NONE;
 
+import bdv.viewer.ConverterSetups;
 import java.awt.BorderLayout;
 import java.awt.Font;
 import java.awt.GridBagConstraints;
@@ -55,7 +55,6 @@ import net.imglib2.util.Intervals;
 import org.scijava.ui.behaviour.io.InputTriggerConfig;
 import org.scijava.ui.behaviour.util.TriggerBehaviourBindings;
 
-import bdv.tools.brightness.SetupAssignments;
 import bdv.viewer.ViewerPanel;
 
 /**
@@ -82,7 +81,8 @@ public class TransformedRealBoxSelectionDialog extends AbstractTransformedBoxSel
 
 	public TransformedRealBoxSelectionDialog(
 			final ViewerPanel viewer,
-			final SetupAssignments setupAssignments,
+			final ConverterSetups converterSetups,
+			final int setupId,
 			final InputTriggerConfig keyConfig,
 			final TriggerBehaviourBindings triggerbindings,
 			final AffineTransform3D boxTransform,
@@ -109,7 +109,8 @@ public class TransformedRealBoxSelectionDialog extends AbstractTransformedBoxSel
 		boxEditor = new TransformedBoxEditor(
 				keyConfig,
 				viewer,
-				setupAssignments,
+				converterSetups,
+				setupId,
 				triggerbindings,
 				model );
 		boxEditor.setPerspective( 1, 1000 ); // TODO expose, initialize from rangeInterval
diff --git a/src/main/java/bdv/tools/brightness/BrightnessDialog.java b/src/main/java/bdv/tools/brightness/BrightnessDialog.java
index 34b60ebdb603371c0b09900d14b5c934d0e2811d..906694afeaff3fe09901b30ef20d60a980abadf1 100644
--- a/src/main/java/bdv/tools/brightness/BrightnessDialog.java
+++ b/src/main/java/bdv/tools/brightness/BrightnessDialog.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -31,13 +30,9 @@ package bdv.tools.brightness;
 
 import java.awt.BorderLayout;
 import java.awt.Color;
-import java.awt.Component;
 import java.awt.Container;
 import java.awt.Dimension;
 import java.awt.Frame;
-import java.awt.Graphics;
-import java.awt.Graphics2D;
-import java.awt.RenderingHints;
 import java.awt.Window;
 import java.awt.event.ActionEvent;
 import java.awt.event.ActionListener;
@@ -47,12 +42,12 @@ import java.awt.event.KeyEvent;
 import java.lang.reflect.InvocationTargetException;
 import java.util.ArrayList;
 
+import java.util.concurrent.atomic.AtomicBoolean;
 import javax.swing.AbstractAction;
 import javax.swing.Action;
 import javax.swing.ActionMap;
 import javax.swing.BorderFactory;
 import javax.swing.BoxLayout;
-import javax.swing.Icon;
 import javax.swing.InputMap;
 import javax.swing.JButton;
 import javax.swing.JCheckBox;
@@ -65,11 +60,11 @@ import javax.swing.JSpinner;
 import javax.swing.KeyStroke;
 import javax.swing.SpinnerNumberModel;
 import javax.swing.SwingUtilities;
-import javax.swing.WindowConstants;
 import javax.swing.event.ChangeEvent;
 import javax.swing.event.ChangeListener;
 
 import bdv.util.InvokeOnEDT;
+import bdv.util.DelayedPackDialog;
 import mpicbg.spim.data.generic.sequence.BasicViewSetup;
 import net.imglib2.type.numeric.ARGBType;
 
@@ -77,9 +72,10 @@ import net.imglib2.type.numeric.ARGBType;
 /**
  * Adjust brightness and colors for individual (or groups of) {@link BasicViewSetup setups}.
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
-public class BrightnessDialog extends JDialog
+@Deprecated
+public class BrightnessDialog extends DelayedPackDialog
 {
 	public BrightnessDialog( final Frame owner, final SetupAssignments setupAssignments )
 	{
@@ -108,6 +104,8 @@ public class BrightnessDialog extends JDialog
 		im.put( KeyStroke.getKeyStroke( KeyEvent.VK_ESCAPE, 0 ), hideKey );
 		am.put( hideKey, hideAction );
 
+		final AtomicBoolean recreateContentPending = new AtomicBoolean();
+
 		setupAssignments.setUpdateListener( new SetupAssignments.UpdateListener()
 		{
 			@Override
@@ -116,8 +114,17 @@ public class BrightnessDialog extends JDialog
 				try
 				{
 					InvokeOnEDT.invokeAndWait( () -> {
-						colorsPanel.recreateContent();
-						minMaxPanels.recreateContent();
+						if ( isVisible() )
+						{
+							System.out.println( "colorsPanel.recreateContent()" );
+							colorsPanel.recreateContent();
+							minMaxPanels.recreateContent();
+							recreateContentPending.set( false );
+						}
+						else
+						{
+							recreateContentPending.set( true );
+						}
 					} );
 				}
 				catch ( InvocationTargetException | InterruptedException e )
@@ -127,44 +134,20 @@ public class BrightnessDialog extends JDialog
 			}
 		} );
 
-		pack();
-		setDefaultCloseOperation( WindowConstants.HIDE_ON_CLOSE );
-	}
-
-	/**
-	 * Adapted from http://stackoverflow.com/a/3072979/230513
-	 */
-	private static class ColorIcon implements Icon
-	{
-		private final int size = 16;
-
-		private final Color color;
-
-		public ColorIcon( final Color color )
-		{
-			this.color = color;
-		}
-
-		@Override
-		public void paintIcon( final Component c, final Graphics g, final int x, final int y )
+		addComponentListener( new ComponentAdapter()
 		{
-			final Graphics2D g2d = ( Graphics2D ) g;
-			g2d.setRenderingHint( RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON );
-			g2d.setColor( color );
-			g2d.fillOval( x, y, size, size );
-		}
-
-		@Override
-		public int getIconWidth()
-		{
-			return size;
-		}
+			@Override
+			public void componentShown( final ComponentEvent e )
+			{
+				if ( recreateContentPending.getAndSet( false ) )
+				{
+					colorsPanel.recreateContent();
+					minMaxPanels.recreateContent();
+				}
+			}
+		} );
 
-		@Override
-		public int getIconHeight()
-		{
-			return size;
-		}
+		pack();
 	}
 
 	public static class ColorsPanel extends JPanel
@@ -196,27 +179,22 @@ public class BrightnessDialog extends JDialog
 			for ( final ConverterSetup setup : setupAssignments.getConverterSetups() )
 			{
 				final JButton button = new JButton( new ColorIcon( getColor( setup ) ) );
-				button.addActionListener( new ActionListener()
-				{
-					@Override
-					public void actionPerformed( final ActionEvent e )
+				button.addActionListener( e -> {
+					colorChooser.setColor( getColor( setup ) );
+					final JDialog d = JColorChooser.createDialog( button, "Choose a color", true, colorChooser, new ActionListener()
 					{
-						colorChooser.setColor( getColor( setup ) );
-						final JDialog d = JColorChooser.createDialog( button, "Choose a color", true, colorChooser, new ActionListener()
+						@Override
+						public void actionPerformed( final ActionEvent arg0 )
 						{
-							@Override
-							public void actionPerformed( final ActionEvent arg0 )
+							final Color c = colorChooser.getColor();
+							if (c != null)
 							{
-								final Color c = colorChooser.getColor();
-								if (c != null)
-								{
-									button.setIcon( new ColorIcon( c ) );
-									setColor( setup, c );
-								}
+								button.setIcon( new ColorIcon( c ) );
+								setColor( setup, c );
 							}
-						}, null );
-						d.setVisible( true );
-					}
+						}
+					}, null );
+					d.setVisible( true );
 				} );
 				button.setEnabled( setup.supportsColor() );
 				buttons.add( button );
@@ -237,7 +215,7 @@ public class BrightnessDialog extends JDialog
 				return new Color( value );
 			}
 			else
-				return new Color ( 0xFFBBBBBB );
+				return null;
 		}
 
 		private static void setColor( final ConverterSetup setup, final Color color )
diff --git a/src/main/java/bdv/tools/brightness/ColorIcon.java b/src/main/java/bdv/tools/brightness/ColorIcon.java
new file mode 100644
index 0000000000000000000000000000000000000000..271153cdf92f9d2bae846eb5f4461987d02aad31
--- /dev/null
+++ b/src/main/java/bdv/tools/brightness/ColorIcon.java
@@ -0,0 +1,144 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.tools.brightness;
+
+import java.awt.Color;
+import java.awt.Component;
+import java.awt.Graphics;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import javax.swing.Icon;
+
+/**
+ * Adapted from http://stackoverflow.com/a/3072979/230513
+ */
+public class ColorIcon implements Icon
+{
+	private final int width;
+
+	private final int height;
+
+	private final boolean drawAsCircle;
+
+	private final int arcWidth;
+
+	private final int arcHeight;
+
+	private final boolean drawOutline;
+
+	private final Color color;
+
+	private final int size; // == min(width, height)
+
+	private final int ox;
+
+	private final int oy;
+
+	public ColorIcon( final Color color )
+	{
+		this( color, 16, 16, true );
+	}
+
+	public ColorIcon( final Color color, final int width, final int height, final boolean drawAsCircle )
+	{
+		this( color, width, height, drawAsCircle, 3, 3, false );
+	}
+
+	public ColorIcon( final Color color, final int width, final int height, final int arcWidth, final int arcHeight )
+	{
+		this( color, width, height, false, arcWidth, arcHeight, false );
+	}
+
+	public ColorIcon( final Color color, final int width, final int height, final int arcWidth, final int arcHeight, final boolean drawOutline )
+	{
+		this( color, width, height, false, arcWidth, arcHeight, drawOutline );
+	}
+
+	private ColorIcon( final Color color, final int width, final int height, final boolean drawAsCircle, final int arcWidth, final int arcHeight, final boolean drawOutline )
+	{
+		this.color = color;
+		this.width = width;
+		this.height = height;
+		this.drawAsCircle = drawAsCircle;
+		this.arcWidth = arcWidth;
+		this.arcHeight = arcHeight;
+		this.drawOutline = drawOutline;
+
+		size = Math.min( width, height );
+		ox = ( width - size ) / 2;
+		oy = ( height - size ) / 2;
+	}
+
+	@Override
+	public void paintIcon( final Component c, final Graphics g, final int x, final int y )
+	{
+		final Graphics2D g2d = ( Graphics2D ) g;
+		g2d.setRenderingHint( RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON );
+		final int x0 = x + ox;
+		final int y0 = y + oy;
+		if ( color == null )
+		{
+			g2d.setColor( new Color( 0xffbcbc ) );
+			g2d.fillArc( x0, y0, size, size, 0, 120 );
+			g2d.setColor( new Color( 0xbcffbc ) );
+			g2d.fillArc( x0, y0, size, size, 120, 120 );
+			g2d.setColor( new Color( 0xbcbcff ) );
+			g2d.fillArc( x0, y0, size, size, 240, 120 );
+		}
+		else
+		{
+			g2d.setColor( color );
+			if ( drawAsCircle )
+				g2d.fillOval( x0, y0, size, size );
+			else
+				g2d.fillRoundRect( x, y, width, height, arcWidth, arcHeight );
+
+			if ( drawOutline )
+			{
+				g2d.setColor( c.isFocusOwner() ? new Color( 0x8FC4F9 ) : Color.gray );
+				if ( drawAsCircle )
+					g2d.drawOval( x0, y0, size, size );
+				else
+					g2d.drawRoundRect( x, y, width, height, arcWidth, arcHeight );
+			}
+		}
+	}
+
+	@Override
+	public int getIconWidth()
+	{
+		return width;
+	}
+
+	@Override
+	public int getIconHeight()
+	{
+		return height;
+	}
+}
diff --git a/src/main/java/bdv/tools/brightness/ConverterSetup.java b/src/main/java/bdv/tools/brightness/ConverterSetup.java
index e79ab42d25f0fbf9076039ffbba5029f031adead..b928d220540461bfc80f5097d545b243d0681fe9 100644
--- a/src/main/java/bdv/tools/brightness/ConverterSetup.java
+++ b/src/main/java/bdv/tools/brightness/ConverterSetup.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -29,43 +28,61 @@
  */
 package bdv.tools.brightness;
 
+import net.imglib2.type.numeric.ARGBType;
+
+import org.scijava.listeners.Listeners;
+
 import bdv.viewer.RequestRepaint;
 import mpicbg.spim.data.generic.sequence.BasicViewSetup;
-import net.imglib2.type.numeric.ARGBType;
 
 /**
  * Modify the range and color of the converter for a source. Because each source
  * can have its own converter, this is used to adjust brightness, contrast, and
  * color of individual sources.
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
 public interface ConverterSetup
 {
+	/**
+	 * {@link SetupChangeListener}s are notified about changes to a
+	 * {@code ConverterSetup}.
+	 */
+	interface SetupChangeListener
+	{
+		void setupParametersChanged( ConverterSetup setup );
+	}
+
+	/**
+	 * {@code SetupChangeListener}s can be added/removed here, and will be
+	 * notified about changes to this {@code ConverterSetup}.
+	 */
+	Listeners< SetupChangeListener > setupChangeListeners();
+
 	/**
 	 * Get the id of the {@link BasicViewSetup} this converter acts on.
 	 *
 	 * @return the id of the {@link BasicViewSetup} this converter acts on.
 	 */
-	public int getSetupId();
+	int getSetupId();
 
 	/**
 	 * Set the range of source values that is mapped to the full range of the
 	 * target type. Source values outside of the specified range are clamped.
 	 *
 	 * @param min
-	 *            source value to map to minimum of the target range.
+	 * 		source value to map to minimum of the target range.
 	 * @param max
-	 *            source value to map to maximum of the target range.
+	 * 		source value to map to maximum of the target range.
 	 */
-	public void setDisplayRange( double min, double max );
+	void setDisplayRange( double min, double max );
 
 	/**
 	 * Set the color for this converter.
 	 */
-	public void setColor( final ARGBType color );
+	void setColor( ARGBType color );
 
-	public boolean supportsColor();
+	boolean supportsColor();
 
 	/**
 	 * Get the (largest) source value that is mapped to the minimum of the
@@ -73,7 +90,7 @@ public interface ConverterSetup
 	 *
 	 * @return source value that is mapped to the minimum of the target range.
 	 */
-	public double getDisplayRangeMin();
+	double getDisplayRangeMin();
 
 	/**
 	 * Get the (smallest) source value that is mapped to the maximum of the
@@ -81,20 +98,26 @@ public interface ConverterSetup
 	 *
 	 * @return source value that is mapped to the maximum of the target range.
 	 */
-	public double getDisplayRangeMax();
+	double getDisplayRangeMax();
 
 	/**
 	 * Get the color for this converter.
 	 *
 	 * @return the color for this converter.
 	 */
-	public ARGBType getColor();
+	ARGBType getColor();
 
 	/**
+	 * Deprecated: Use "{@code setupChangeListeners().add( s -> viewer.requestRepaint() )}" instead.
+	 * <p>
 	 * Set the {@link RequestRepaint} that should be notified if
 	 * {@link ConverterSetup} settings change.
 	 *
 	 * @param viewer
 	 */
-	public void setViewer( final RequestRepaint viewer );
+	@Deprecated
+	default void setViewer( RequestRepaint viewer )
+	{
+		setupChangeListeners().add( s -> viewer.requestRepaint() );
+	}
 }
diff --git a/src/main/java/bdv/tools/brightness/MinMaxGroup.java b/src/main/java/bdv/tools/brightness/MinMaxGroup.java
index 8f9b8c11fc57630301fb5b4b6c81f5b1f3964a08..de3ce938bcef80be980c049ca50eca8ad1017596 100644
--- a/src/main/java/bdv/tools/brightness/MinMaxGroup.java
+++ b/src/main/java/bdv/tools/brightness/MinMaxGroup.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -50,7 +49,7 @@ import bdv.util.BoundedValueDouble;
  * An {@link UpdateListener} (usually a GUI component) can be notified about
  * changes.
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
 public class MinMaxGroup extends BoundedIntervalDouble
 {
@@ -62,7 +61,7 @@ public class MinMaxGroup extends BoundedIntervalDouble
 
 	public interface UpdateListener
 	{
-		public void update();
+		void update();
 	}
 
 	private UpdateListener updateListener;
diff --git a/src/main/java/bdv/tools/brightness/RealARGBColorConverterSetup.java b/src/main/java/bdv/tools/brightness/RealARGBColorConverterSetup.java
index f79066c3a0c6514642592aafc6b13fff4dd5e3a0..ea6e0849f71992cf71508439598780c7c9aa93bb 100644
--- a/src/main/java/bdv/tools/brightness/RealARGBColorConverterSetup.java
+++ b/src/main/java/bdv/tools/brightness/RealARGBColorConverterSetup.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -32,49 +31,75 @@ package bdv.tools.brightness;
 import java.util.Arrays;
 import java.util.List;
 
-import bdv.viewer.RequestRepaint;
 import net.imglib2.display.ColorConverter;
 import net.imglib2.type.numeric.ARGBType;
 
+import org.scijava.listeners.Listeners;
+
 public class RealARGBColorConverterSetup implements ConverterSetup
 {
-	protected final int id;
+	private final int id;
 
-	protected final List< ColorConverter > converters;
+	private final List< ColorConverter > converters;
 
-	protected RequestRepaint viewer;
+	private final Listeners.List< SetupChangeListener > listeners;
 
-	public RealARGBColorConverterSetup( final int setupId, final ColorConverter ... converters )
+	public RealARGBColorConverterSetup( final int setupId, final ColorConverter... converters )
 	{
-		this( setupId, Arrays.< ColorConverter >asList( converters ) );
+		this( setupId, Arrays.asList( converters ) );
 	}
 
 	public RealARGBColorConverterSetup( final int setupId, final List< ColorConverter > converters )
 	{
 		this.id = setupId;
 		this.converters = converters;
-		this.viewer = null;
+		this.listeners = new Listeners.SynchronizedList<>();
+	}
+
+	@Override
+	public Listeners< SetupChangeListener > setupChangeListeners()
+	{
+		return listeners;
 	}
 
 	@Override
 	public void setDisplayRange( final double min, final double max )
 	{
+		boolean changed = false;
 		for ( final ColorConverter converter : converters )
 		{
-			converter.setMin( min );
-			converter.setMax( max );
+			if ( converter.getMin() != min )
+			{
+				converter.setMin( min );
+				changed = true;
+			}
+			if ( converter.getMax() != max )
+			{
+				converter.setMax( max );
+				changed = true;
+			}
 		}
-		if ( viewer != null )
-			viewer.requestRepaint();
+		if ( changed )
+			listeners.list.forEach( l -> l.setupParametersChanged( this ) );
 	}
 
 	@Override
 	public void setColor( final ARGBType color )
 	{
+		if ( !supportsColor() )
+			return;
+
+		boolean changed = false;
 		for ( final ColorConverter converter : converters )
-			converter.setColor( color );
-		if ( viewer != null )
-			viewer.requestRepaint();
+		{
+			if ( converter.getColor().get() != color.get() )
+			{
+				converter.setColor( color );
+				changed = true;
+			}
+		}
+		if ( changed )
+			listeners.list.forEach( l -> l.setupParametersChanged( this ) );
 	}
 
 	@Override
@@ -106,10 +131,4 @@ public class RealARGBColorConverterSetup implements ConverterSetup
 	{
 		return converters.get( 0 ).getColor();
 	}
-
-	@Override
-	public void setViewer( final RequestRepaint viewer )
-	{
-		this.viewer = viewer;
-	}
 }
diff --git a/src/main/java/bdv/tools/brightness/SetupAssignments.java b/src/main/java/bdv/tools/brightness/SetupAssignments.java
index 83c3357ecc0770f3e1b29233e7d811d1fbf8a371..6e343899b210bcfd58540b622b84f6d5e462063f 100644
--- a/src/main/java/bdv/tools/brightness/SetupAssignments.java
+++ b/src/main/java/bdv/tools/brightness/SetupAssignments.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -50,8 +49,9 @@ import mpicbg.spim.data.XmlHelpers;
  * <li>No group is empty.</li>
  * </ol>
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
+@Deprecated
 public class SetupAssignments
 {
 	/**
@@ -83,7 +83,7 @@ public class SetupAssignments
 
 	public interface UpdateListener
 	{
-		public void update();
+		void update();
 	}
 
 	private UpdateListener updateListener;
diff --git a/src/main/java/bdv/tools/brightness/SliderPanel.java b/src/main/java/bdv/tools/brightness/SliderPanel.java
index f91f7db0e05052d482e5cb9154741f87c279ae37..5043425cbca27619039247227eea0be7e928ea59 100644
--- a/src/main/java/bdv/tools/brightness/SliderPanel.java
+++ b/src/main/java/bdv/tools/brightness/SliderPanel.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/tools/brightness/SliderPanelDouble.java b/src/main/java/bdv/tools/brightness/SliderPanelDouble.java
index 821e78202f7325556a8d7ec6d0a1f0a9cf59f63c..463413dfc42396afa97aaa7a9b6fb52426ef6186 100644
--- a/src/main/java/bdv/tools/brightness/SliderPanelDouble.java
+++ b/src/main/java/bdv/tools/brightness/SliderPanelDouble.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -71,9 +70,9 @@ public class SliderPanelDouble extends JPanel implements BoundedValueDouble.Upda
 
 	private RangeListener rangeListener;
 
-	public static interface RangeListener
+	public interface RangeListener
 	{
-		public void rangeChanged();
+		void rangeChanged();
 	}
 
 	/**
diff --git a/src/main/java/bdv/tools/crop/CropDialog.java b/src/main/java/bdv/tools/crop/CropDialog.java
index 30dc1217d3e811653f8882908cb85e7f76bd89ea..25a2c0c334fdf6ba9e9f3d82a8ff2c8c28050990 100644
--- a/src/main/java/bdv/tools/crop/CropDialog.java
+++ b/src/main/java/bdv/tools/crop/CropDialog.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -29,6 +28,8 @@
  */
 package bdv.tools.crop;
 
+import bdv.viewer.SourceAndConverter;
+import bdv.viewer.ViewerState;
 import java.awt.BorderLayout;
 import java.awt.Frame;
 import java.awt.event.ActionEvent;
@@ -71,7 +72,6 @@ import bdv.spimdata.XmlIoSpimDataMinimal;
 import bdv.tools.transformation.TransformedSource;
 import bdv.viewer.Source;
 import bdv.viewer.ViewerPanel;
-import bdv.viewer.state.SourceState;
 import mpicbg.spim.data.SpimDataException;
 import mpicbg.spim.data.generic.sequence.AbstractSequenceDescription;
 import mpicbg.spim.data.generic.sequence.BasicViewSetup;
@@ -101,7 +101,7 @@ public class CropDialog extends JDialog
 	{
 		if ( b )
 		{
-			final int tp = viewer.getState().getCurrentTimepoint();
+			final int tp = viewer.state().getCurrentTimepoint();
 			spinnerMinTimepoint.setValue( tp );
 			spinnerMaxTimepoint.setValue( tp );
 		}
@@ -291,9 +291,6 @@ public class CropDialog extends JDialog
 	 */
 	public void cropGlobal( final int minTimepointIndex, final int maxTimepointIndex, final File hdf5File, final File xmlFile ) throws SpimDataException
 	{
-		final AffineTransform3D globalToCropTransform = new AffineTransform3D();
-		viewer.getState().getViewerTransform( globalToCropTransform );
-
 		final int w = viewer.getDisplay().getWidth();
 		final int h = viewer.getDisplay().getHeight();
 		final int d = Math.min( w, h );
@@ -322,30 +319,37 @@ public class CropDialog extends JDialog
 		// This is needed because the CropImgLoader is asked for (timepointId,
 		// setupId) pair and needs to retrieve from corresponding source.
 		final HashMap< Integer, Integer > setupIdToSourceIndex = new HashMap<>();
-		for( final SourceState< ? > s : viewer.getState().getSources() )
+
+		final AffineTransform3D globalToCropTransform = new AffineTransform3D();
+		final ViewerState state = viewer.state();
+		synchronized ( state )
 		{
-			Source< ? > source = s.getSpimSource();
-			sources.add( source );
+			state.getViewerTransform( globalToCropTransform );
+			for ( SourceAndConverter< ? > s : state.getSources() )
+			{
+				Source< ? > source = s.getSpimSource();
+				sources.add( source );
 
-			// try to find the BasicViewSetup for the source
-			final BasicViewSetup setup;
+				// try to find the BasicViewSetup for the source
+				final BasicViewSetup setup;
 
-			// strip TransformedSource wrapper
-			while ( source instanceof TransformedSource )
-				source = ( ( TransformedSource< ? > ) source ).getWrappedSource();
+				// strip TransformedSource wrapper
+				while ( source instanceof TransformedSource )
+					source = ( ( TransformedSource< ? > ) source ).getWrappedSource();
 
-			if ( source instanceof AbstractSpimSource )
-			{
-				 final int setupId = ( ( AbstractSpimSource< ? > ) source ).getSetupId();
-				 setup = sequenceDescription.getViewSetups().get( setupId );
-			}
-			else
-			{
-				final int setupId = nextSetupIndex++;
-				setup = new BasicViewSetup( setupId, Integer.toString( setupId ), null, null );
+				if ( source instanceof AbstractSpimSource )
+				{
+					final int setupId = ( ( AbstractSpimSource< ? > ) source ).getSetupId();
+					setup = sequenceDescription.getViewSetups().get( setupId );
+				}
+				else
+				{
+					final int setupId = nextSetupIndex++;
+					setup = new BasicViewSetup( setupId, Integer.toString( setupId ), null, null );
+				}
+				cropSetups.put( setup.getId(), setup );
+				setupIdToSourceIndex.put( setup.getId(), sources.size() - 1 );
 			}
-			cropSetups.put( setup.getId(), setup );
-			setupIdToSourceIndex.put( setup.getId(), sources.size() - 1 );
 		}
 
 		// Map from timepoint id to timepoint index (in the list of timepoints
diff --git a/src/main/java/bdv/tools/crop/CropImgLoader.java b/src/main/java/bdv/tools/crop/CropImgLoader.java
index e24fb30ab2ada4a6408a3e0758b24a17d2bf1ce6..afa3d502821788bbda3cd8f8781661669a9ba82b 100644
--- a/src/main/java/bdv/tools/crop/CropImgLoader.java
+++ b/src/main/java/bdv/tools/crop/CropImgLoader.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -57,7 +56,7 @@ import bdv.viewer.Source;
  * This {@link ImgLoader} provides views and transformations into a cropped
  * region of a data-set (provided by list of {@link Source Sources}).
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
 public class CropImgLoader implements BasicImgLoader
 {
diff --git a/src/main/java/bdv/tools/transformation/ManualSourceTransforms.java b/src/main/java/bdv/tools/transformation/ManualSourceTransforms.java
index 59db360a575a19fc238a729310cdfcdb7a80654d..b6fe5c4940073c26f46a1857ccc2320b865ad2f8 100644
--- a/src/main/java/bdv/tools/transformation/ManualSourceTransforms.java
+++ b/src/main/java/bdv/tools/transformation/ManualSourceTransforms.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/tools/transformation/ManualTransformActiveListener.java b/src/main/java/bdv/tools/transformation/ManualTransformActiveListener.java
index 552665133848c7e76e98748ba031a2ebd9ddebe8..9087a7cd41499d66c167afff16847b837331586a 100644
--- a/src/main/java/bdv/tools/transformation/ManualTransformActiveListener.java
+++ b/src/main/java/bdv/tools/transformation/ManualTransformActiveListener.java
@@ -1,9 +1,8 @@
 /*-
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -31,5 +30,5 @@ package bdv.tools.transformation;
 
 public interface ManualTransformActiveListener
 {
-	public void manualTransformActiveChanged( final boolean acitve );
+	void manualTransformActiveChanged( final boolean active );
 }
diff --git a/src/main/java/bdv/tools/transformation/ManualTransformation.java b/src/main/java/bdv/tools/transformation/ManualTransformation.java
index 70e209b7bd8e76a49c86e1aeb2f1d2345892210f..ff726ff99e74397b0928720196613732e958525e 100644
--- a/src/main/java/bdv/tools/transformation/ManualTransformation.java
+++ b/src/main/java/bdv/tools/transformation/ManualTransformation.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -29,6 +28,8 @@
  */
 package bdv.tools.transformation;
 
+import bdv.viewer.SynchronizedViewerState;
+import bdv.viewer.ViewerState;
 import java.util.ArrayList;
 import java.util.List;
 
@@ -88,14 +89,23 @@ public class ManualTransformation
 
 	private ArrayList< TransformedSource< ? > > getTransformedSources()
 	{
+		final List< ? extends SourceAndConverter< ? > > sourceList;
+		if ( sources != null )
+			sourceList = sources;
+		else
+		{
+			final ViewerState state = viewer.state();
+			synchronized ( state )
+			{
+				sourceList = new ArrayList<>( state.getSources() );
+			}
+		}
+
 		final ArrayList< TransformedSource< ? > > list = new ArrayList<>();
-		final List< ? extends SourceAndConverter< ? > > sourceList = ( sources != null )
-				? sources
-				: viewer.getState().getSources();
 		for ( final SourceAndConverter< ? > soc : sourceList )
 		{
 			final Source< ? > source = soc.getSpimSource();
-			if ( TransformedSource.class.isInstance( source ) )
+			if ( source instanceof TransformedSource )
 				list.add( (TransformedSource< ? > ) source );
 		}
 		return list;
diff --git a/src/main/java/bdv/tools/transformation/ManualTransformationEditor.java b/src/main/java/bdv/tools/transformation/ManualTransformationEditor.java
index 3575d87bd693e957fd30d7c795b7189339a3f0b6..d0f9146ba36c40c5a1f6b7d5be222052e1331973 100644
--- a/src/main/java/bdv/tools/transformation/ManualTransformationEditor.java
+++ b/src/main/java/bdv/tools/transformation/ManualTransformationEditor.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -29,27 +28,21 @@
  */
 package bdv.tools.transformation;
 
-import java.awt.event.ActionEvent;
+import bdv.viewer.SourceAndConverter;
+import bdv.viewer.ViewerPanel;
+import bdv.viewer.ViewerState;
 import java.awt.event.KeyEvent;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.concurrent.CopyOnWriteArrayList;
-
-import javax.swing.AbstractAction;
 import javax.swing.Action;
 import javax.swing.ActionMap;
 import javax.swing.InputMap;
 import javax.swing.KeyStroke;
-
-import org.scijava.ui.behaviour.util.InputActionBindings;
-
-import bdv.viewer.Source;
-import bdv.viewer.ViewerPanel;
-import bdv.viewer.state.SourceGroup;
-import bdv.viewer.state.ViewerState;
 import net.imglib2.realtransform.AffineTransform3D;
-import net.imglib2.ui.TransformListener;
-
+import bdv.viewer.TransformListener;
+import org.scijava.listeners.Listeners;
+import org.scijava.ui.behaviour.util.InputActionBindings;
+import org.scijava.ui.behaviour.util.RunnableAction;
 
 // TODO: what happens when the current source, display mode, etc is changed while the editor is active? deactivate?
 public class ManualTransformationEditor implements TransformListener< AffineTransform3D >
@@ -72,7 +65,7 @@ public class ManualTransformationEditor implements TransformListener< AffineTran
 
 	private final InputMap inputMap;
 
-	protected final CopyOnWriteArrayList< ManualTransformActiveListener > manualTransformActiveListeners;
+	private final Listeners.List< ManualTransformActiveListener > manualTransformActiveListeners;
 
 	public ManualTransformationEditor( final ViewerPanel viewer, final InputActionBindings inputActionBindings )
 	{
@@ -82,30 +75,12 @@ public class ManualTransformationEditor implements TransformListener< AffineTran
 		liveTransform = new AffineTransform3D();
 		sourcesToModify = new ArrayList<>();
 		sourcesToFix = new ArrayList<>();
-		manualTransformActiveListeners = new CopyOnWriteArrayList<>();
+		manualTransformActiveListeners = new Listeners.SynchronizedList<>();
 
 		final KeyStroke abortKey = KeyStroke.getKeyStroke( KeyEvent.VK_ESCAPE, 0 );
-		final Action abortAction = new AbstractAction( "abort manual transformation" )
-		{
-			@Override
-			public void actionPerformed( final ActionEvent e )
-			{
-				abort();
-			}
-
-			private static final long serialVersionUID = 1L;
-		};
+		final Action abortAction = new RunnableAction( "abort manual transformation", this::abort );
 		final KeyStroke resetKey = KeyStroke.getKeyStroke( KeyEvent.VK_R, 0 );
-		final Action resetAction = new AbstractAction( "reset manual transformation" )
-		{
-			@Override
-			public void actionPerformed( final ActionEvent e )
-			{
-				reset();
-			}
-
-			private static final long serialVersionUID = 1L;
-		};
+		final Action resetAction = new RunnableAction( "reset manual transformation", this::reset );
 		actionMap = new ActionMap();
 		inputMap = new InputMap();
 		actionMap.put( "abort manual transformation", abortAction );
@@ -122,9 +97,10 @@ public class ManualTransformationEditor implements TransformListener< AffineTran
 			final AffineTransform3D identity = new AffineTransform3D();
 			for ( final TransformedSource< ? > source : sourcesToModify )
 				source.setIncrementalTransform( identity );
-			viewer.setCurrentViewerTransform( frozenTransform );
+			viewer.state().setViewerTransform( frozenTransform );
 			viewer.showMessage( "aborted manual transform" );
 			active = false;
+			manualTransformActiveListeners.list.forEach( l -> l.manualTransformActiveChanged( active ) );
 		}
 	}
 
@@ -142,28 +118,28 @@ public class ManualTransformationEditor implements TransformListener< AffineTran
 			{
 				source.setIncrementalTransform( identity );
 			}
-			viewer.setCurrentViewerTransform( frozenTransform );
+			viewer.state().setViewerTransform( frozenTransform );
 			viewer.showMessage( "reset manual transform" );
 		}
 	}
 
 	public synchronized void setActive( final boolean a )
 	{
-		if ( this.active == a ) { return; }
+		if ( this.active == a )
+			return;
+
 		if ( a )
 		{
-			active = a;
 			// Enter manual edit mode
-			final ViewerState state = viewer.getState();
-			final List< Integer > indices = new ArrayList<>();
+			final ViewerState state = viewer.state().snapshot();
+			final List< SourceAndConverter< ? > > currentSources = new ArrayList<>();
 			switch ( state.getDisplayMode() )
 			{
 			case FUSED:
-				indices.add( state.getCurrentSource() );
+				currentSources.add( state.getCurrentSource() );
 				break;
 			case FUSEDGROUP:
-				final SourceGroup group = state.getSourceGroups().get( state.getCurrentGroup() );
-				indices.addAll( group.getSourceIds() );
+				currentSources.addAll( state.getSourcesInGroup( state.getCurrentGroup() ) );
 				break;
 			default:
 				viewer.showMessage( "Can only do manual transformation when in FUSED mode." );
@@ -172,16 +148,14 @@ public class ManualTransformationEditor implements TransformListener< AffineTran
 			state.getViewerTransform( frozenTransform );
 			sourcesToModify.clear();
 			sourcesToFix.clear();
-			final int numSources = state.numSources();
-			for ( int i = 0; i < numSources; ++i )
+			for ( SourceAndConverter< ? > source : state.getSources() )
 			{
-				final Source< ? > source = state.getSources().get( i ).getSpimSource();
-				if ( TransformedSource.class.isInstance( source ) )
+				if ( source.getSpimSource() instanceof TransformedSource )
 				{
-					if ( indices.contains( i ) )
-						sourcesToModify.add( ( bdv.tools.transformation.TransformedSource< ? > ) source );
+					if ( currentSources.contains( source ) )
+						sourcesToModify.add( ( TransformedSource< ? > ) source.getSpimSource() );
 					else
-						sourcesToFix.add( ( bdv.tools.transformation.TransformedSource< ? > ) source );
+						sourcesToFix.add( ( TransformedSource< ? > ) source.getSpimSource() );
 				}
 			}
 			active = true;
@@ -190,7 +164,8 @@ public class ManualTransformationEditor implements TransformListener< AffineTran
 			viewer.showMessage( "starting manual transform" );
 		}
 		else
-		{ // Exit manual edit mode.
+		{
+			// Exit manual edit mode.
 			active = false;
 			viewer.removeTransformListener( this );
 			bindings.removeInputMap( "manual transform" );
@@ -206,13 +181,10 @@ public class ManualTransformationEditor implements TransformListener< AffineTran
 			tmp.identity();
 			for ( final TransformedSource< ? > source : sourcesToFix )
 				source.setIncrementalTransform( tmp );
-			viewer.setCurrentViewerTransform( frozenTransform );
+			viewer.state().setViewerTransform( frozenTransform );
 			viewer.showMessage( "fixed manual transform" );
 		}
-		for ( final ManualTransformActiveListener l : manualTransformActiveListeners )
-		{
-			l.manualTransformActiveChanged( active );
-		}
+		manualTransformActiveListeners.list.forEach( l -> l.manualTransformActiveChanged( active ) );
 	}
 
 	public synchronized void toggle()
@@ -232,13 +204,8 @@ public class ManualTransformationEditor implements TransformListener< AffineTran
 			source.setIncrementalTransform( liveTransform.inverse() );
 	}
 
-	public void addManualTransformActiveListener( final ManualTransformActiveListener l )
-	{
-		manualTransformActiveListeners.add( l );
-	}
-
-	public void removeManualTransformActiveListener( final ManualTransformActiveListener l )
+	public Listeners< ManualTransformActiveListener > manualTransformActiveListeners()
 	{
-		manualTransformActiveListeners.remove( l );
+		return manualTransformActiveListeners;
 	}
 }
diff --git a/src/main/java/bdv/tools/transformation/TransformedSource.java b/src/main/java/bdv/tools/transformation/TransformedSource.java
index 1f76eb0f76b5cbc41577f8cc43a7c01a0fc84d58..0d84ea5c9ae15ecdf14e3dc585f006b18bdaae5a 100644
--- a/src/main/java/bdv/tools/transformation/TransformedSource.java
+++ b/src/main/java/bdv/tools/transformation/TransformedSource.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -122,6 +121,12 @@ public class TransformedSource< T > implements Source< T >, MipmapOrdering
 		this.composed = new AffineTransform3D();
 	}
 
+	@Override
+	public boolean doBoundingBoxCulling()
+	{
+		return source.doBoundingBoxCulling();
+	}
+
 	/*
 	 * EXTRA TRANSFORMATION methods
 	 */
diff --git a/src/main/java/bdv/tools/transformation/XmlIoTransformedSources.java b/src/main/java/bdv/tools/transformation/XmlIoTransformedSources.java
index 881ca14c3530455f847918f71787887bc090abd8..201b0dc330b279b8a0a6e7ee4068addba9cc7056 100644
--- a/src/main/java/bdv/tools/transformation/XmlIoTransformedSources.java
+++ b/src/main/java/bdv/tools/transformation/XmlIoTransformedSources.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/ui/BdvDefaultCards.java b/src/main/java/bdv/ui/BdvDefaultCards.java
new file mode 100644
index 0000000000000000000000000000000000000000..403dd1fcf45d9d2751da5347a4c0a3332ca04301
--- /dev/null
+++ b/src/main/java/bdv/ui/BdvDefaultCards.java
@@ -0,0 +1,198 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.ui;
+
+import bdv.ui.convertersetupeditor.ConverterSetupEditPanel;
+import bdv.ui.sourcegrouptree.SourceGroupTree;
+import bdv.ui.sourcetable.SourceTable;
+import bdv.ui.viewermodepanel.DisplaySettingsPanel;
+import bdv.viewer.ConverterSetups;
+import bdv.viewer.SynchronizedViewerState;
+import bdv.viewer.ViewerPanel;
+import java.awt.BorderLayout;
+import java.awt.Container;
+import java.awt.Dimension;
+import java.awt.Insets;
+import java.awt.KeyboardFocusManager;
+import java.beans.PropertyChangeEvent;
+import java.beans.PropertyChangeListener;
+import java.lang.ref.WeakReference;
+import javax.swing.JComponent;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.border.EmptyBorder;
+import javax.swing.tree.TreeSelectionModel;
+
+/**
+ * Default cards added to the card panel.
+ * <ul>
+ * <li>Display Modes</li>
+ * <li>SourceTable with ConverterSetup editor</li>
+ * <li>SourceGroupTree with ConverterSetup editor</li>
+ * </ul>
+ *
+ * @author Tobias Pietzsch
+ */
+public class BdvDefaultCards
+{
+	public static final String DEFAULT_SOURCES_CARD = "default bdv sources card";
+
+	public static final String DEFAULT_SOURCEGROUPS_CARD = "default bdv groups card";
+
+	public static final String DEFAULT_VIEWERMODES_CARD = "default bdv viewer modes card";
+
+	public static void setup( final CardPanel cards, final ViewerPanel viewer, final ConverterSetups converterSetups )
+	{
+		final SynchronizedViewerState state = viewer.state();
+
+		// -- Sources table --
+		final SourceTable table = new SourceTable( state, converterSetups, viewer.getOptionValues().getInputTriggerConfig() );
+		table.setPreferredScrollableViewportSize( new Dimension( 300, 200 ) );
+		table.setFillsViewportHeight( true );
+		table.setDragEnabled( true );
+		final ConverterSetupEditPanel editPanelTable = new ConverterSetupEditPanel( table, converterSetups );
+		final JPanel tablePanel = new JPanel( new BorderLayout() );
+		final JScrollPane scrollPaneTable = new JScrollPane( table );
+		scrollPaneTable.addMouseWheelListener( new MouseWheelScrollListener( scrollPaneTable ) );
+		scrollPaneTable.setBorder( new EmptyBorder( 0, 0, 0, 0 ) );
+		tablePanel.add( scrollPaneTable, BorderLayout.CENTER );
+		tablePanel.add( editPanelTable, BorderLayout.SOUTH );
+		tablePanel.setPreferredSize( new Dimension( 300, 245 ) );
+
+		// -- Groups tree --
+		final SourceGroupTree tree = new SourceGroupTree( state, viewer.getOptionValues().getInputTriggerConfig() );
+//		tree.setPreferredSize( new Dimension( 300, 200 ) );
+		tree.setVisibleRowCount( 10 );
+		tree.setEditable( true );
+		tree.setSelectionRow( 0 );
+		tree.setRootVisible( false );
+		tree.setShowsRootHandles( true );
+		tree.setExpandsSelectedPaths( true );
+		tree.getSelectionModel().setSelectionMode( TreeSelectionModel.DISCONTIGUOUS_TREE_SELECTION );
+		final ConverterSetupEditPanel editPanelTree = new ConverterSetupEditPanel( tree, converterSetups );
+		final JPanel treePanel = new JPanel( new BorderLayout() );
+		final JScrollPane scrollPaneTree = new JScrollPane( tree );
+		scrollPaneTree.addMouseWheelListener( new MouseWheelScrollListener( scrollPaneTree ) );
+		scrollPaneTree.setBorder( new EmptyBorder( 0, 0, 0, 0 ) );
+		treePanel.add( scrollPaneTree, BorderLayout.CENTER );
+		treePanel.add( editPanelTree, BorderLayout.SOUTH );
+		treePanel.setPreferredSize( new Dimension( 300, 225 ) );
+
+		new FocusListener( tablePanel, table, treePanel, tree );
+
+		cards.addCard( DEFAULT_VIEWERMODES_CARD, "Display Modes", new DisplaySettingsPanel( viewer.state() ), true, new Insets( 0, 4, 4, 0 ) );
+		cards.addCard( DEFAULT_SOURCES_CARD, "Sources", tablePanel, true, new Insets( 0, 4, 0, 0 ) );
+		cards.addCard( DEFAULT_SOURCEGROUPS_CARD, "Groups", treePanel, true, new Insets( 0, 4, 0, 0 ) );
+	}
+
+	private static class FocusListener implements PropertyChangeListener
+	{
+		private final KeyboardFocusManager keyboardFocusManager;
+
+		private final WeakReference< JPanel > tablePanel;
+		private final WeakReference< SourceTable > table;
+		private final WeakReference< JPanel > treePanel;
+		private final WeakReference< SourceGroupTree > tree;
+
+		static final int MAX_DEPTH = 8;
+		boolean tableFocused;
+		boolean treeFocused;
+
+		FocusListener( final JPanel tablePanel, final SourceTable table, final JPanel treePanel, final SourceGroupTree tree )
+		{
+			this.tablePanel = new WeakReference<>( tablePanel );
+			this.table = new WeakReference<>( table );
+			this.treePanel = new WeakReference<>( treePanel );
+			this.tree = new WeakReference<>( tree );
+
+			keyboardFocusManager = KeyboardFocusManager.getCurrentKeyboardFocusManager();
+			keyboardFocusManager.addPropertyChangeListener( "focusOwner", this );
+		}
+
+		void focusTable( final boolean focus )
+		{
+			if ( focus != tableFocused )
+			{
+				tableFocused = focus;
+				final SourceTable table = this.table.get();
+				if ( table != null )
+					table.setSelectionBackground( focus );
+			}
+		}
+
+		void focusTree( final boolean focus )
+		{
+			if ( focus != treeFocused )
+			{
+				treeFocused = focus;
+				final SourceGroupTree tree = this.tree.get();
+				if ( tree != null )
+					tree.setSelectionBackground( focus );
+			}
+		}
+
+		@Override
+		public void propertyChange( final PropertyChangeEvent evt )
+		{
+			final JPanel tablePanel = this.tablePanel.get();
+			final JPanel treePanel = this.treePanel.get();
+			if ( tablePanel == null && treePanel == null )
+			{
+				keyboardFocusManager.removePropertyChangeListener( "focusOwner", this );
+				return;
+			}
+
+			if ( evt.getNewValue() instanceof JComponent )
+			{
+				final JComponent component = ( JComponent ) evt.getNewValue();
+				for ( int i = 0; i < MAX_DEPTH; ++i )
+				{
+					final Container parent = component.getParent();
+					if ( !( parent instanceof JComponent ) )
+						break;
+
+					if ( component == treePanel )
+					{
+						focusTable( false );
+						focusTree( true );
+						return;
+					}
+					else if ( component == tablePanel )
+					{
+						focusTable( true );
+						focusTree( false );
+						return;
+					}
+				}
+				focusTable( false );
+				focusTree( false );
+			}
+		}
+	}
+}
diff --git a/src/main/java/bdv/ui/CardPanel.java b/src/main/java/bdv/ui/CardPanel.java
new file mode 100644
index 0000000000000000000000000000000000000000..7c7c8312450a787ad68d0167641253a4881c67ad
--- /dev/null
+++ b/src/main/java/bdv/ui/CardPanel.java
@@ -0,0 +1,685 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.ui;
+
+import java.awt.Color;
+import java.awt.Component;
+import java.awt.Cursor;
+import java.awt.Dimension;
+import java.awt.Graphics;
+import java.awt.Graphics2D;
+import java.awt.Insets;
+import java.awt.LayoutManager;
+import java.awt.Rectangle;
+import java.awt.RenderingHints;
+import java.awt.event.ComponentAdapter;
+import java.awt.event.ComponentEvent;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.awt.geom.AffineTransform;
+import java.awt.geom.Path2D;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javax.swing.Icon;
+import javax.swing.JComponent;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JToggleButton;
+import javax.swing.Scrollable;
+import javax.swing.border.EmptyBorder;
+import net.miginfocom.swing.MigLayout;
+
+/**
+ * CardPanel handles components in named {@code Card}s which can be expanded or collapsed.
+ *
+ * @author Tim-Oliver Buchholz, MPI-CBG CSBD, Dresden
+ * @author Tobias Pietzsch
+ */
+public class CardPanel
+{
+	/**
+	 * Color scheme.
+	 */
+	private final static Color DEFAULT_CARD_BACKGROUND = Color.white;
+
+	private final static Color DEFAULT_HEADER_BACKGROUND = new Color( 0xcccccc );
+
+	private final static Color DEFAULT_HEADER_FOREGROUND = new Color( 0x202020 );
+
+	private Color headerBackground = DEFAULT_HEADER_BACKGROUND;
+
+	private Color headerForeground = DEFAULT_HEADER_FOREGROUND;
+
+	private final Map< Object, Card > cards = new HashMap<>();
+
+	private final List< Card > cardList = new ArrayList<>();
+
+	private final Container container;
+
+	/**
+	 * Empty card panel.
+	 */
+	public CardPanel()
+	{
+		container = new Container( new MigLayout( "fillx, ins 0", "[grow]", "[]0[]" ) );
+		container.setBackground( DEFAULT_CARD_BACKGROUND );
+	}
+
+	public JComponent getComponent()
+	{
+		return container;
+	}
+
+	/**
+	 * Add a new {@link JComponent} to the card panel.
+	 *
+	 * @param title
+	 * 		title of the new card. Is shown in the card header.
+	 * 		This is also used as the key of the new card.
+	 * 		If a card with this key already exists, no new card will be added!
+	 * @param component
+	 * 		body of the new card
+	 * @param expanded
+	 * 		state of the card
+	 * @param insets
+	 * 		insets of card body
+	 *
+	 * @return if a new card was successfully added
+	 */
+	public boolean addCard( final String title, final JComponent component, final boolean expanded, final Insets insets )
+	{
+		return addCard( title, title, component, expanded, insets );
+	}
+
+	/**
+	 * Add a new {@link JComponent} to the card panel.
+	 *
+	 * @param key
+	 * 		key of the new card.
+	 * 		If a card with this key already exists, no new card will be added!
+	 * @param title
+	 * 		title of the new card. Is shown in the card header.
+	 * @param component
+	 * 		body of the new card
+	 * @param expanded
+	 * 		state of the card
+	 * @param insets
+	 * 		insets of card body
+	 *
+	 * @return if a new card was successfully added
+	 */
+	public synchronized boolean addCard( final Object key, final String title, final JComponent component, final boolean expanded, final Insets insets )
+	{
+		if ( key == null || title == null || component == null )
+			throw new NullPointerException();
+
+		if ( cards.containsKey( key ) )
+			return false;
+
+		final Card card = new Card( key, title, component, expanded, insets );
+		cards.put( key, card );
+		if ( !cardList.isEmpty() )
+			cardList.get( cardList.size() - 1 ).setIsLastCard( false );
+		cardList.add( card );
+		container.add( card, "growx, wrap" );
+		container.revalidate();
+		return true;
+	}
+
+	public boolean addCard( final String title, final JComponent component, final boolean expanded )
+	{
+		return addCard( title, title, component, expanded, null );
+	}
+
+	public boolean addCard( final Object key, final String title, final JComponent component, final boolean expanded )
+	{
+		return addCard( key, title, component, expanded, null );
+	}
+
+	/**
+	 * Remove a card. If the card does not exist, nothing happens.
+	 *
+	 * @param key
+	 * 		of the card
+	 */
+	public synchronized void removeCard( final Object key )
+	{
+		final Card card = cards.remove( key );
+		if ( card != null )
+		{
+			cardList.remove( card );
+			final Card lastCard = cardList.get( cardList.size() - 1 );
+			lastCard.setIsLastCard( true );
+			container.remove( card );
+			container.revalidate();
+		}
+	}
+
+	/**
+	 * TODO
+	 *
+	 * @param key
+	 *
+	 * @return
+	 */
+	public synchronized int indexOf( final Object key )
+	{
+		if ( cards.containsKey( key ) )
+			for ( int i = 0; i < cardList.size(); i++ )
+				if ( cardList.get( i ).getKey().equals( key ) )
+					return i;
+		return -1;
+	}
+
+	/**
+	 * Get expanded state of a card.
+	 *
+	 * @param key
+	 * 		of the card
+	 *
+	 * @return open state
+	 */
+	public synchronized boolean isCardExpanded( final Object key )
+	{
+		final Card card = cards.get( key );
+		return card != null && card.isExpanded();
+	}
+
+	/**
+	 * Set open state of a card
+	 *
+	 * @param key
+	 * 		of the card
+	 * @param expanded
+	 * 		new state
+	 */
+	public synchronized void setCardExpanded( final Object key, final boolean expanded )
+	{
+		final Card card = cards.get( key );
+		if ( card != null && card.isExpanded() != expanded )
+			card.setExpanded( expanded );
+	}
+
+	/**
+	 * Set the card background color
+	 */
+	public void setCardBackground( final Color bg )
+	{
+		container.setBackground( bg );
+		for ( Card card : cardList )
+		{
+			card.setBackground( bg );
+			card.componentPanel.setBackground( bg );
+		}
+	}
+
+	public Color getCardBackground()
+	{
+		return container.getBackground();
+	}
+
+	/**
+	 * Set the header background color
+	 */
+	public void setHeaderBackground( final Color bg )
+	{
+		headerBackground = bg;
+		for ( Card card : cardList )
+		{
+			card.headerPanel.setBackground( bg );
+			card.terminalResizePanel.setBackground( bg );
+		}
+	}
+
+	public Color getHeaderBackground()
+	{
+		return headerBackground;
+	}
+
+	/**
+	 * Set the header foreground color
+	 */
+	public void setHeaderForeground( final Color fg )
+	{
+		headerForeground = fg;
+		for ( Card card : cardList )
+			card.headerPanel.setForeground( fg );
+	}
+
+	public Color getHeaderForeground()
+	{
+		return headerForeground;
+	}
+
+	// =================================================================
+
+	private static final Icon collapsedIcon = new CardCollapseIcon( 10, 10, true, false );
+	private static final Icon collapsedMouseOverIcon = new CardCollapseIcon( 10, 10, true, true );
+	private static final Icon expandedIcon = new CardCollapseIcon( 10, 10, false, false );
+	private static final Icon expandedMouseOverIcon = new CardCollapseIcon( 10, 10, false, true );
+
+	private static final int RESIZE_HANDLE_HEIGHT = 10;
+
+	private static class TerminalResizePanel extends JPanel
+	{
+		public TerminalResizePanel( final JComponent component )
+		{
+			UIUtils.setMinimumHeight( this, 5 );
+			UIUtils.setPreferredHeight( this, 5 );
+
+			final ResizeMouseHandler resizeHandler = new ResizeMouseHandler( this, new Resizable()
+			{
+				@Override
+				public boolean showResizeHandle()
+				{
+					return true;
+				}
+
+				@Override
+				public JComponent getComponent()
+				{
+					return component;
+				}
+			} );
+			addMouseListener( resizeHandler );
+			addMouseMotionListener( resizeHandler );
+		}
+	}
+
+	private class Card extends JPanel
+	{
+		private final JPanel componentPanel;
+
+		private final HeaderPanel headerPanel;
+
+		private final TerminalResizePanel terminalResizePanel;
+
+		/**
+		 * Card key.
+		 */
+		private final Object key;
+
+		private boolean isLastCard = true;
+
+		public Card( final Object key, final String title, final JComponent component, final boolean open, final Insets insets )
+		{
+			this.key = key;
+
+			this.setLayout( new MigLayout( "fillx, ins 0, hidemode 3", "[grow]", "[]0lp![]" ) );
+			this.setBackground( getCardBackground() );
+
+			final String ins = insets == null ? "ins 4 4 4 0" : String.format( "ins %d %d %d %d", insets.top, insets.left, insets.bottom, insets.right );
+			componentPanel = new JPanel( new MigLayout( "fillx, hidemode 3, " + ins, "[grow]", "[grow]0lp![]" ) );
+			componentPanel.setBackground( getCardBackground() );
+			componentPanel.add( component, "grow, wrap" );
+
+			terminalResizePanel = new TerminalResizePanel( componentPanel );
+			terminalResizePanel.setBackground( getHeaderBackground() );
+
+			headerPanel = new HeaderPanel( title );
+			headerPanel.setBackground( getHeaderBackground() );
+			headerPanel.setForeground( getHeaderForeground() );
+
+			this.add( headerPanel, "growx, wrap" );
+			this.add( componentPanel, "growx, wrap" );
+			this.add( terminalResizePanel, "growx" );
+			this.setExpanded( open );
+		}
+
+		private class HeaderPanel extends JPanel
+		{
+			private final JPanel labelPanel;
+
+			private final JLabel label;
+
+			@Override
+			public void setBackground( final Color bg )
+			{
+				super.setBackground( bg );
+				if ( labelPanel != null )
+					labelPanel.setBackground( bg );
+			}
+
+			@Override
+			public void setForeground( final Color fg )
+			{
+				super.setForeground( fg );
+				if ( label != null )
+					label.setForeground( fg );
+			}
+
+			public HeaderPanel( final String title )
+			{
+				super( new MigLayout( "fillx, aligny center, ins 0 0 0 0", "[][grow]", "" ) );
+				UIUtils.setPreferredWidth( this, 100 );
+
+				// Holds the name with insets.
+				labelPanel = new JPanel( new MigLayout( "fillx, ins 0 4 0 4", "[grow]", "" ) );
+				label = new JLabel( title );
+				labelPanel.add( label );
+
+				final JToggleButton collapseButton = new JToggleButton();
+				collapseButton.setIcon( expandedIcon );
+				collapseButton.setRolloverIcon( expandedMouseOverIcon );
+				collapseButton.setSelectedIcon( collapsedIcon );
+				collapseButton.setRolloverSelectedIcon( collapsedMouseOverIcon );
+				collapseButton.setFocusable( false );
+				collapseButton.setBorder( new EmptyBorder( 5, 5, 5, 5 ) );
+				collapseButton.setBorderPainted( false );
+				collapseButton.setFocusPainted( false );
+				collapseButton.setContentAreaFilled( false );
+
+				collapseButton.addActionListener( e -> setExpanded( !collapseButton.isSelected() ) );
+
+				componentPanel.addComponentListener( new ComponentAdapter()
+				{
+					@Override
+					public void componentShown( final ComponentEvent e )
+					{
+						collapseButton.setSelected( false );
+					}
+
+					@Override
+					public void componentHidden( final ComponentEvent e )
+					{
+						collapseButton.setSelected( true );
+					}
+				} );
+
+				final ResizeMouseHandler resizeHandler = new ResizeMouseHandler( this, new Resizable()
+				{
+					@Override
+					public boolean showResizeHandle()
+					{
+						return isCardAboveExpanded();
+					}
+
+					@Override
+					public JComponent getComponent()
+					{
+						return getContentOfCardAbove();
+					}
+				} );
+				addMouseListener( resizeHandler );
+				addMouseMotionListener( resizeHandler );
+
+				add( collapseButton );
+				add( labelPanel, "growx" );
+			}
+		}
+
+		/**
+		 * Get the key of this card. Keys are unique within one CardPanel.
+		 */
+		public Object getKey()
+		{
+			return key;
+		}
+
+		public boolean isExpanded()
+		{
+			return componentPanel.isVisible();
+		}
+
+		public void setExpanded( final boolean open )
+		{
+			componentPanel.setVisible( open );
+			terminalResizePanel.setVisible( open && isLastCard );
+			componentPanel.revalidate();
+		}
+
+		public void setIsLastCard( final boolean isLastCard )
+		{
+			this.isLastCard = isLastCard;
+			terminalResizePanel.setVisible( componentPanel.isVisible() && isLastCard );
+			terminalResizePanel.revalidate();
+		}
+
+		@Override
+		public boolean equals( final Object obj )
+		{
+			return obj instanceof Card && this.key.equals( ( ( Card ) obj ).key );
+		}
+
+		@Override
+		public int hashCode()
+		{
+			return key.hashCode();
+		}
+
+		private JComponent getContentOfCardAbove()
+		{
+			synchronized ( CardPanel.this )
+			{
+				final int i = cardList.indexOf( this );
+				if ( i <= 0 )
+					return null;
+
+				final JPanel component = cardList.get( i - 1 ).componentPanel;
+				return component.isVisible() ? component : null;
+			}
+		}
+
+		private boolean isCardAboveExpanded()
+		{
+			synchronized ( CardPanel.this )
+			{
+				final int i = cardList.indexOf( this );
+				if ( i <= 0 )
+					return false;
+
+				return cardList.get( i - 1 ).isExpanded();
+			}
+		}
+	}
+
+	// =================================================================
+
+	private interface Resizable
+	{
+		boolean showResizeHandle();
+
+		JComponent getComponent();
+	}
+
+	private static class ResizeMouseHandler extends MouseAdapter
+	{
+		private final JComponent attachedComponent;
+
+		private final Resizable resizable;
+
+		private int oy;
+
+		private int oheight;
+
+		private int minheight;
+
+		private int maxheight;
+
+		private JComponent resizeComponent;
+
+		public ResizeMouseHandler( final JComponent attachedComponent, final Resizable resizable )
+		{
+			this.attachedComponent = attachedComponent;
+			this.resizable = resizable;
+		}
+
+		@Override
+		public void mousePressed( final MouseEvent e )
+		{
+			if ( e.getY() < RESIZE_HANDLE_HEIGHT )
+				resizeComponent = resizable.getComponent();
+			else
+				resizeComponent = null;
+
+			if ( resizeComponent != null )
+			{
+				oheight = resizeComponent.getHeight();
+				minheight = resizeComponent.getMinimumSize().height;
+				maxheight = resizeComponent.getMaximumSize().height;
+				UIUtils.setPreferredHeight( resizeComponent, oheight );
+				oy = e.getYOnScreen();
+			}
+		}
+
+		@Override
+		public void mouseMoved( final MouseEvent e )
+		{
+			if ( e.getY() < RESIZE_HANDLE_HEIGHT && resizable.showResizeHandle() )
+				attachedComponent.setCursor( Cursor.getPredefinedCursor( Cursor.N_RESIZE_CURSOR ) );
+			else
+				attachedComponent.setCursor( Cursor.getPredefinedCursor( Cursor.DEFAULT_CURSOR ) );
+		}
+
+		@Override
+		public void mouseExited( final MouseEvent e )
+		{
+			attachedComponent.setCursor( Cursor.getPredefinedCursor( Cursor.DEFAULT_CURSOR ) );
+		}
+
+		@Override
+		public void mouseDragged( final MouseEvent e )
+		{
+			if ( resizeComponent != null )
+			{
+				final int height = oheight + e.getYOnScreen() - oy;
+				UIUtils.setPreferredHeight( resizeComponent, Math.min( maxheight, Math.max( minheight, height ) ) );
+				resizeComponent.revalidate();
+			}
+		}
+	}
+
+	// =================================================================
+
+	/**
+	 * Scroll-savvy JPanel that tracks viewport width
+	 */
+	private static class Container extends JPanel implements Scrollable
+	{
+		public Container( final LayoutManager layout )
+		{
+			super( layout );
+		}
+
+		@Override
+		public Dimension getPreferredScrollableViewportSize()
+		{
+			return getPreferredSize();
+		}
+
+		@Override
+		public int getScrollableUnitIncrement( final Rectangle visibleRect, final int orientation, final int direction )
+		{
+			return 16;
+		}
+
+		@Override
+		public int getScrollableBlockIncrement( final Rectangle visibleRect, final int orientation, final int direction )
+		{
+			return 10;
+		}
+
+		@Override
+		public boolean getScrollableTracksViewportWidth()
+		{
+			return true;
+		}
+
+		@Override
+		public boolean getScrollableTracksViewportHeight()
+		{
+			return false;
+		}
+	}
+
+	// =================================================================
+
+	private static class CardCollapseIcon implements Icon
+	{
+		private static final Color mouseOverColor = new Color(0x606060 );
+
+		private static final Color color = new Color( 0x808080 );
+
+		private final int width;
+
+		private final int height;
+
+		private final boolean collapsed;
+
+		private final boolean mouseOver;
+
+		public CardCollapseIcon( final int width, final int height, final boolean collapsed, final boolean mouseOver )
+		{
+			this.width = width;
+			this.height = height;
+			this.collapsed = collapsed;
+			this.mouseOver = mouseOver;
+		}
+
+		@Override
+		public void paintIcon( final Component c, final Graphics g, final int x, final int y )
+		{
+			final Graphics2D g2d = ( Graphics2D ) g;
+			g2d.setRenderingHint( RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON );
+
+			final Path2D.Double path = new Path2D.Double();
+			if ( collapsed )
+			{
+				path.moveTo( 0.05, 0.05 );
+				path.lineTo( 0.85, 0.5 );
+				path.lineTo( 0.05, 0.95 );
+			}
+			else
+			{
+				path.moveTo( 0.05, 0.05 );
+				path.lineTo( 0.5, 0.85 );
+				path.lineTo( 0.95, 0.05 );
+			}
+			path.transform( AffineTransform.getScaleInstance( width, height ) );
+			path.transform( AffineTransform.getTranslateInstance( x, y ) );
+
+			g2d.setColor( mouseOver ? mouseOverColor : color );
+			g2d.fill( path );
+		}
+
+		@Override
+		public int getIconWidth()
+		{
+			return width;
+		}
+
+		@Override
+		public int getIconHeight()
+		{
+			return height;
+		}
+	}
+}
diff --git a/src/main/java/bdv/ui/MouseWheelScrollListener.java b/src/main/java/bdv/ui/MouseWheelScrollListener.java
new file mode 100644
index 0000000000000000000000000000000000000000..fa6b880c392826e9b6b0542f477022e09ebf17cd
--- /dev/null
+++ b/src/main/java/bdv/ui/MouseWheelScrollListener.java
@@ -0,0 +1,98 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+
+package bdv.ui;
+
+import javax.swing.*;
+import java.awt.*;
+import java.awt.event.MouseWheelEvent;
+import java.awt.event.MouseWheelListener;
+
+/**
+ * Passes mouse wheel events to the parent component if this component
+ * cannot scroll further in the given direction.
+ * <p>
+ * This behavior is a little better than Swing's default behavior but
+ * still worse than the behavior of Google Chrome, which remembers the
+ * currently scrolling component and sticks to it until a timeout happens.
+ *
+ * @see <a href="https://stackoverflow.com/a/53687022">Stack Overflow</a>
+ */
+final class MouseWheelScrollListener implements MouseWheelListener
+{
+
+	private final JScrollPane pane;
+
+	private int previousValue;
+
+	private boolean parentSearched = false;
+
+	private Component parent = null;
+
+	public MouseWheelScrollListener( JScrollPane pane )
+	{
+		this.pane = pane;
+		previousValue = pane.getVerticalScrollBar().getValue();
+	}
+
+	public void mouseWheelMoved( MouseWheelEvent e )
+	{
+
+		if ( !parentSearched )
+		{
+			if ( !searchParentScrollPane() )
+				return;
+		}
+		if ( parent == null )
+			return;
+		JScrollBar bar = pane.getVerticalScrollBar();
+		int limit = e.getWheelRotation() < 0 ? 0 : bar.getMaximum() - bar.getVisibleAmount();
+		if ( previousValue == limit && bar.getValue() == limit )
+		{
+			parent.dispatchEvent( SwingUtilities.convertMouseEvent( pane, e, parent ) );
+		}
+		previousValue = bar.getValue();
+	}
+
+	private boolean searchParentScrollPane()
+	{
+		parentSearched = true;
+		Component parent = pane.getParent();
+		while ( !( parent instanceof JScrollPane ) )
+		{
+			if ( parent == null )
+			{
+				return false;
+			}
+			parent = parent.getParent();
+		}
+		this.parent = parent;
+		return true;
+	}
+}
diff --git a/src/main/java/bdv/ui/SourcesTransferable.java b/src/main/java/bdv/ui/SourcesTransferable.java
new file mode 100644
index 0000000000000000000000000000000000000000..596e9e8b48541f46a55e80ecf48855214e5863af
--- /dev/null
+++ b/src/main/java/bdv/ui/SourcesTransferable.java
@@ -0,0 +1,89 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.ui;
+
+import bdv.viewer.SourceAndConverter;
+import java.awt.datatransfer.DataFlavor;
+import java.awt.datatransfer.Transferable;
+import java.awt.datatransfer.UnsupportedFlavorException;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Drag and Drop {@code Transferable} for a collection of sources.
+ */
+public class SourcesTransferable implements Transferable
+{
+	public static final DataFlavor flavor = new DataFlavor( SourceList.class, DataFlavor.javaJVMLocalObjectMimeType );
+
+	static final DataFlavor[] transferDataFlavors = { flavor };
+
+	public static class SourceList
+	{
+		private final ArrayList< SourceAndConverter< ? > > sources;
+
+		public List< SourceAndConverter< ? > > getSources()
+		{
+			return sources;
+		}
+
+		SourceList( final Collection< SourceAndConverter< ? > > sources )
+		{
+			this.sources = new ArrayList<>( sources );
+		}
+	}
+
+	private final SourceList sources;
+
+	public SourcesTransferable( final Collection< SourceAndConverter< ? > > sources )
+	{
+		this.sources = new SourceList( sources );
+	}
+
+	@Override
+	public DataFlavor[] getTransferDataFlavors()
+	{
+		return transferDataFlavors;
+	}
+
+	@Override
+	public boolean isDataFlavorSupported( final DataFlavor flavor )
+	{
+		return Objects.equals( flavor, this.flavor );
+	}
+
+	@Override
+	public Object getTransferData( final DataFlavor flavor ) throws UnsupportedFlavorException, IOException
+	{
+		return sources;
+	}
+}
diff --git a/src/main/java/bdv/ui/UIUtils.java b/src/main/java/bdv/ui/UIUtils.java
new file mode 100644
index 0000000000000000000000000000000000000000..696668013b20a972726421100c3e8fc99ac63440
--- /dev/null
+++ b/src/main/java/bdv/ui/UIUtils.java
@@ -0,0 +1,85 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.ui;
+
+import java.awt.Color;
+import java.awt.Component;
+import java.awt.Dimension;
+
+/**
+ * AWT/Swing helpers.
+ *
+ * @author Tobias Pietzsch
+ */
+public class UIUtils
+{
+	/**
+	 * Mix colors {@code c1} and {@code c2} by ratios {@code c1Weight} and {@code (1-c1Weight)}, respectively.
+	 */
+	public 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 ) );
+	}
+
+	/**
+	 * Set the preferred width of a component (leaving the preferred height untouched).
+	 */
+	public static void setPreferredWidth( final Component component, final int preferredWidth )
+	{
+		component.setPreferredSize( new Dimension( preferredWidth, component.getPreferredSize().height ) );
+	}
+
+	/**
+	 * Set the preferred height of a component (leaving the preferred width untouched).
+	 */
+	public static void setPreferredHeight( final Component component, final int preferredHeight )
+	{
+		component.setPreferredSize( new Dimension( component.getPreferredSize().width, preferredHeight ) );
+	}
+
+	/**
+	 * Set the minimum width of a component (leaving the minimum height untouched).
+	 */
+	public static void setMinimumWidth( final Component component, final int minimumWidth )
+	{
+		component.setMinimumSize( new Dimension( minimumWidth, component.getMinimumSize().height ) );
+	}
+
+	/**
+	 * Set the minimum height of a component (leaving the minimum width untouched).
+	 */
+	public static void setMinimumHeight( final Component component, final int minimumHeight )
+	{
+		component.setMinimumSize( new Dimension( component.getMinimumSize().width, minimumHeight ) );
+	}
+}
diff --git a/src/main/java/bdv/ui/convertersetupeditor/BoundedRangeEditor.java b/src/main/java/bdv/ui/convertersetupeditor/BoundedRangeEditor.java
new file mode 100644
index 0000000000000000000000000000000000000000..e7ddc89beed218525e6d35625fe4917c537a0d38
--- /dev/null
+++ b/src/main/java/bdv/ui/convertersetupeditor/BoundedRangeEditor.java
@@ -0,0 +1,232 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.ui.convertersetupeditor;
+
+import bdv.tools.brightness.ConverterSetup;
+import bdv.viewer.ConverterSetupBounds;
+import bdv.viewer.ConverterSetups;
+import bdv.ui.sourcetable.SourceTable;
+import bdv.util.BoundedRange;
+import bdv.util.Bounds;
+import bdv.ui.sourcegrouptree.SourceGroupTree;
+import bdv.ui.UIUtils;
+import java.awt.Color;
+import java.util.List;
+import java.util.function.Supplier;
+import javax.swing.JMenuItem;
+import javax.swing.JPopupMenu;
+import javax.swing.SwingUtilities;
+import javax.swing.event.TreeModelEvent;
+import javax.swing.event.TreeModelListener;
+
+class BoundedRangeEditor
+{
+	private final Supplier< List< ConverterSetup > > selectedConverterSetups;
+
+	private final BoundedRangePanel rangePanel;
+
+	private final ConverterSetupBounds converterSetupBounds;
+
+	private final Color equalColor;
+
+	private final Color notEqualColor;
+
+	public BoundedRangeEditor(
+			final SourceTable table,
+			final ConverterSetups converterSetups,
+			final BoundedRangePanel rangePanel,
+			final ConverterSetupBounds converterSetupBounds )
+	{
+		this( table::getSelectedConverterSetups, converterSetups, rangePanel, converterSetupBounds );
+		table.getSelectionModel().addListSelectionListener( e -> updateSelection() );
+	}
+
+	public BoundedRangeEditor(
+			final SourceGroupTree tree,
+			final ConverterSetups converterSetups,
+			final BoundedRangePanel rangePanel,
+			final ConverterSetupBounds converterSetupBounds )
+	{
+		this(
+				() -> converterSetups.getConverterSetups( tree.getSelectedSources() ),
+				converterSetups, rangePanel, converterSetupBounds );
+		tree.getSelectionModel().addTreeSelectionListener( e -> updateSelection() );
+		tree.getModel().addTreeModelListener( new TreeModelListener()
+		{
+			@Override
+			public void treeNodesChanged( final TreeModelEvent e )
+			{
+				updateSelection();
+			}
+
+			@Override
+			public void treeNodesInserted( final TreeModelEvent e )
+			{
+				updateSelection();
+			}
+
+			@Override
+			public void treeNodesRemoved( final TreeModelEvent e )
+			{
+				updateSelection();
+			}
+
+			@Override
+			public void treeStructureChanged( final TreeModelEvent e )
+			{
+				updateSelection();
+			}
+		} );
+	}
+
+	private BoundedRangeEditor(
+			final Supplier< List< ConverterSetup > > selectedConverterSetups,
+			final ConverterSetups converterSetups,
+			final BoundedRangePanel rangePanel, final ConverterSetupBounds converterSetupBounds )
+	{
+		this.selectedConverterSetups = selectedConverterSetups;
+		this.rangePanel = rangePanel;
+		this.converterSetupBounds = converterSetupBounds;
+
+		rangePanel.changeListeners().add( this::updateConverterSetupRanges );
+
+		converterSetups.listeners().add( s -> updateRangePanel() );
+
+		equalColor = rangePanel.getBackground();
+		notEqualColor = UIUtils.mix( equalColor, Color.red, 0.9 );
+
+		final JPopupMenu menu = new JPopupMenu();
+		menu.add( runnableItem(  "set bounds ...", rangePanel::setBoundsDialog ) );
+		menu.add( setBoundsItem( "set bounds 0..1", 0, 1 ) );
+		menu.add( setBoundsItem( "set bounds 0..255", 0, 255 ) );
+		menu.add( setBoundsItem( "set bounds 0..65535", 0, 65535 ) );
+		menu.add( runnableItem(  "shrink bounds to selection", rangePanel::shrinkBoundsToRange ) );
+		rangePanel.setPopup( () -> menu );
+
+		updateRangePanel();
+	}
+
+	private JMenuItem setBoundsItem( final String text, final double min, final double max )
+	{
+		final JMenuItem item = new JMenuItem( text );
+		item.addActionListener( e -> setBounds( new Bounds( min, max ) ) );
+		return item;
+	}
+
+	private JMenuItem runnableItem( final String text, final Runnable action )
+	{
+		final JMenuItem item = new JMenuItem( text );
+		item.addActionListener( e -> action.run() );
+		return item;
+	}
+
+	private boolean blockUpdates = false;
+
+	private List< ConverterSetup > converterSetups;
+
+	private synchronized void setBounds( final Bounds bounds )
+	{
+		if ( converterSetups == null || converterSetups.isEmpty() )
+			return;
+
+		for ( final ConverterSetup converterSetup : converterSetups )
+		{
+			converterSetupBounds.setBounds( converterSetup, bounds );
+		}
+
+		updateRangePanel();
+	}
+
+	private synchronized void updateConverterSetupRanges()
+	{
+		if ( blockUpdates || converterSetups == null || converterSetups.isEmpty() )
+			return;
+
+		final BoundedRange range = rangePanel.getRange();
+
+		for ( final ConverterSetup converterSetup : converterSetups )
+		{
+			converterSetup.setDisplayRange( range.getMin(), range.getMax() );
+			converterSetupBounds.setBounds( converterSetup, range.getBounds() );
+		}
+
+		updateRangePanel();
+	}
+
+	private synchronized void updateSelection()
+	{
+		converterSetups = selectedConverterSetups.get();
+		updateRangePanel();
+	}
+
+	private synchronized void updateRangePanel()
+	{
+		if ( converterSetups == null || converterSetups.isEmpty() )
+		{
+			SwingUtilities.invokeLater( () -> {
+				rangePanel.setEnabled( false );
+				rangePanel.setBackground( equalColor );
+			} );
+		}
+		else
+		{
+			BoundedRange range = null;
+			boolean allRangesEqual = true;
+			for ( final ConverterSetup converterSetup : converterSetups )
+			{
+				final Bounds bounds = converterSetupBounds.getBounds( converterSetup );
+				final double minBound = bounds.getMinBound();
+				final double maxBound = bounds.getMaxBound();
+				final double min = converterSetup.getDisplayRangeMin();
+				final double max = converterSetup.getDisplayRangeMax();
+
+				final BoundedRange converterSetupRange = new BoundedRange( minBound, maxBound, min, max );
+				if ( range == null )
+					range = converterSetupRange;
+				else
+				{
+					allRangesEqual &= range.equals( converterSetupRange );
+					range = range.join( converterSetupRange );
+				}
+			}
+			final BoundedRange finalRange = range;
+			final Color bg = allRangesEqual ? equalColor : notEqualColor;
+			SwingUtilities.invokeLater( () -> {
+				synchronized ( BoundedRangeEditor.this )
+				{
+					blockUpdates = true;
+					rangePanel.setEnabled( true );
+					rangePanel.setRange( finalRange );
+					rangePanel.setBackground( bg );
+					blockUpdates = false;
+				}
+			} );
+		}
+	}
+}
diff --git a/src/main/java/bdv/ui/convertersetupeditor/BoundedRangePanel.java b/src/main/java/bdv/ui/convertersetupeditor/BoundedRangePanel.java
new file mode 100644
index 0000000000000000000000000000000000000000..b4e49fa2699b89f1fb446ecfdfdc20f7888db995
--- /dev/null
+++ b/src/main/java/bdv/ui/convertersetupeditor/BoundedRangePanel.java
@@ -0,0 +1,412 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.ui.convertersetupeditor;
+
+import java.awt.Color;
+import java.awt.Font;
+import java.awt.event.ComponentAdapter;
+import java.awt.event.ComponentEvent;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.awt.event.MouseListener;
+import java.text.DecimalFormat;
+import java.util.Objects;
+import java.util.function.Supplier;
+
+import javax.swing.JFormattedTextField;
+import javax.swing.JLabel;
+import javax.swing.JOptionPane;
+import javax.swing.JPanel;
+import javax.swing.JPopupMenu;
+import javax.swing.JSpinner;
+import javax.swing.SpinnerNumberModel;
+import javax.swing.event.ChangeEvent;
+import javax.swing.text.DefaultFormatterFactory;
+import javax.swing.text.NumberFormatter;
+
+import net.miginfocom.swing.MigLayout;
+
+import org.scijava.listeners.Listeners;
+
+import bdv.ui.UIUtils;
+import bdv.ui.rangeslider.RangeSlider;
+import bdv.util.BoundedRange;
+
+/**
+ * A {@code JPanel} with a range slider, min/max spinners, and a range bounds
+ * display (for setting {@code ConverterSetup} display range).
+ *
+ * @author Tobias Pietzsch
+ */
+class BoundedRangePanel extends JPanel
+{
+	private Supplier< JPopupMenu > popup;
+
+	public interface ChangeListener
+	{
+		void boundedRangeChanged();
+	}
+
+	private BoundedRange range;
+
+	/**
+	 * The range slider.
+	 */
+	private final RangeSlider rangeSlider;
+
+	/**
+	 * Range slider number of steps.
+	 */
+	private static final int SLIDER_LENGTH = 10000;
+
+	/**
+	 * The minimum spinner.
+	 */
+	private final JSpinner minSpinner;
+
+	/**
+	 * The maximum spinner.
+	 */
+	private final JSpinner maxSpinner;
+
+	private final JLabel upperBoundLabel;
+
+	private final JLabel lowerBoundLabel;
+
+	private final Listeners.List< ChangeListener > listeners = new Listeners.SynchronizedList<>();
+
+	public BoundedRangePanel()
+	{
+		this( new BoundedRange( 0, 1, 0, 0.5 ) );
+	}
+
+	public BoundedRangePanel( final BoundedRange range )
+	{
+		setLayout( new MigLayout( "ins 5 5 5 10, fillx, filly, hidemode 3", "[][grow][][]", "[]0[]" ) );
+
+		minSpinner = new JSpinner( new SpinnerNumberModel( 0.0, 0.0, 1.0, 1.0 ) );
+		maxSpinner = new JSpinner( new SpinnerNumberModel( 1.0, 0.0, 1.0, 1.0 ) );
+		rangeSlider = new RangeSlider( 0, SLIDER_LENGTH );
+		upperBoundLabel = new JLabel();
+		lowerBoundLabel = new JLabel();
+
+		setupMinSpinner();
+		setupMaxSpinner();
+		setupRangeSlider();
+		setupBoundLabels();
+		setupPopupMenu();
+
+		this.add( minSpinner, "sy 2" );
+		this.add( rangeSlider, "growx, sy 2" );
+		this.add( maxSpinner, "sy 2" );
+		this.add( upperBoundLabel, "right, wrap" );
+		this.add( lowerBoundLabel, "right" );
+
+		setRange( range );
+	}
+
+	@Override
+	public void setEnabled( final boolean enabled )
+	{
+		super.setEnabled( enabled );
+		if ( minSpinner != null )
+			minSpinner.setEnabled( enabled );
+		if ( rangeSlider != null )
+			rangeSlider.setEnabled( enabled );
+		if ( maxSpinner != null )
+			maxSpinner.setEnabled( enabled );
+		if ( upperBoundLabel != null )
+			upperBoundLabel.setEnabled( enabled );
+		if ( lowerBoundLabel != null )
+			lowerBoundLabel.setEnabled( enabled );
+	}
+
+	@Override
+	public void setBackground( final Color bg )
+	{
+		super.setBackground( bg );
+		if ( minSpinner != null )
+			minSpinner.setBackground( bg );
+		if ( rangeSlider != null )
+			rangeSlider.setBackground( bg );
+		if ( maxSpinner != null )
+			maxSpinner.setBackground( bg );
+		if ( upperBoundLabel != null )
+			upperBoundLabel.setBackground( bg );
+		if ( lowerBoundLabel != null )
+			lowerBoundLabel.setBackground( bg );
+	}
+
+	private static class UnboundedNumberEditor extends JSpinner.NumberEditor
+	{
+		public UnboundedNumberEditor( final JSpinner spinner )
+		{
+			super( spinner );
+			final JFormattedTextField ftf = getTextField();
+			final DecimalFormat format = ( DecimalFormat ) ( ( NumberFormatter ) ftf.getFormatter() ).getFormat();
+			final NumberFormatter formatter = new NumberFormatter( format );
+			formatter.setValueClass( spinner.getValue().getClass() );
+			final DefaultFormatterFactory factory = new DefaultFormatterFactory( formatter );
+			ftf.setFormatterFactory( factory );
+		}
+	}
+
+	private void setupMinSpinner()
+	{
+		UIUtils.setPreferredWidth( minSpinner, 70 );
+
+		minSpinner.addChangeListener( e -> {
+			final double value = ( Double ) minSpinner.getValue();
+			if ( value != range.getMin() )
+				updateRange( range.withMin( value ) );
+		} );
+
+		minSpinner.setEditor( new UnboundedNumberEditor( minSpinner ) );
+	}
+
+	private void setupMaxSpinner()
+	{
+		UIUtils.setPreferredWidth( maxSpinner, 70 );
+
+		maxSpinner.addChangeListener( e -> {
+			final double value = ( Double ) maxSpinner.getValue();
+			if ( value != range.getMax() )
+				updateRange( range.withMax( value ) );
+		} );
+
+		maxSpinner.setEditor( new UnboundedNumberEditor( maxSpinner ) );
+	}
+
+	private void setupRangeSlider()
+	{
+		UIUtils.setPreferredWidth( rangeSlider, 50 );
+		rangeSlider.setRange( 0, SLIDER_LENGTH );
+		rangeSlider.setFocusable( false );
+
+		rangeSlider.addChangeListener( e -> {
+			updateRange( range.withMin( posToValue( rangeSlider.getValue() ) ).withMax( posToValue( rangeSlider.getUpperValue() ) ) );
+		} );
+
+		rangeSlider.addComponentListener( new ComponentAdapter()
+		{
+			@Override
+			public void componentResized( final ComponentEvent e )
+			{
+				updateNumberFormat();
+			}
+		} );
+	}
+
+	private void setupBoundLabels()
+	{
+		final Font font = upperBoundLabel.getFont().deriveFont( 10f );
+		upperBoundLabel.setFont( font );
+		lowerBoundLabel.setFont( font );
+		upperBoundLabel.setBorder( null );
+		lowerBoundLabel.setBorder( null );
+	}
+
+	private void setupPopupMenu()
+	{
+		final MouseListener ml = new MouseAdapter()
+		{
+			@Override
+			public void mousePressed( final MouseEvent e )
+			{
+				if ( e.isPopupTrigger() ||
+						( e.getButton() == MouseEvent.BUTTON1 && e.getX() > upperBoundLabel.getX() ) )
+					doPop( e );
+			}
+
+			@Override
+			public void mouseReleased( final MouseEvent e )
+			{
+				if ( e.isPopupTrigger() )
+					doPop( e );
+			}
+
+			private void doPop( final MouseEvent e )
+			{
+				if ( isEnabled() && popup != null )
+				{
+					final JPopupMenu menu = popup.get();
+					if ( menu != null )
+						menu.show( e.getComponent(), e.getX(), e.getY() );
+				}
+			}
+		};
+		this.addMouseListener( ml );
+		rangeSlider.addMouseListener( ml );
+	}
+
+	/**
+	 * Convert range-slider position to value.
+	 *
+	 * @param pos
+	 *            of range-slider
+	 */
+	private double posToValue( final int pos )
+	{
+		final double dmin = range.getMinBound();
+		final double dmax = range.getMaxBound();
+		return ( pos * ( dmax - dmin ) / SLIDER_LENGTH ) + dmin;
+	}
+
+	/**
+	 * Convert value to range-slider position.
+	 */
+	private int valueToPos( final double value )
+	{
+		final double dmin = range.getMinBound();
+		final double dmax = range.getMaxBound();
+		return ( int ) Math.round( ( value - dmin ) * SLIDER_LENGTH / ( dmax - dmin ) );
+	}
+
+	private synchronized void updateNumberFormat()
+	{
+//		if ( userDefinedNumberFormat )
+//			return;
+
+		final int sw = rangeSlider.getWidth();
+		if ( sw > 0 )
+		{
+			final double vrange = range.getMaxBound() - range.getMinBound();
+			final int digits = ( int ) Math.ceil( Math.log10( sw / vrange ) );
+
+			blockUpdates = true;
+
+			JSpinner.NumberEditor numberEditor = ( ( JSpinner.NumberEditor ) minSpinner.getEditor() );
+			numberEditor.getFormat().setMaximumFractionDigits( digits );
+			numberEditor.stateChanged( new ChangeEvent( minSpinner ) );
+
+			numberEditor = ( ( JSpinner.NumberEditor ) maxSpinner.getEditor() );
+			numberEditor.getFormat().setMaximumFractionDigits( digits );
+			numberEditor.stateChanged( new ChangeEvent( maxSpinner ) );
+
+			blockUpdates = false;
+		}
+	}
+
+	private synchronized void updateRange( final BoundedRange newRange )
+	{
+		if ( !blockUpdates )
+			setRange( newRange );
+	}
+
+	private boolean blockUpdates = false;
+
+	public synchronized void setRange( final BoundedRange range )
+	{
+		if ( Objects.equals( this.range, range ) )
+			return;
+
+		this.range = range;
+
+		blockUpdates = true;
+
+		final double minBound = range.getMinBound();
+		final double maxBound = range.getMaxBound();
+
+		final SpinnerNumberModel minSpinnerModel = ( SpinnerNumberModel ) minSpinner.getModel();
+		minSpinnerModel.setMinimum( minBound );
+		minSpinnerModel.setMaximum( maxBound );
+		minSpinnerModel.setValue( range.getMin() );
+
+		final SpinnerNumberModel maxSpinnerModel = ( SpinnerNumberModel ) maxSpinner.getModel();
+		maxSpinnerModel.setMinimum( minBound );
+		maxSpinnerModel.setMaximum( maxBound );
+		maxSpinnerModel.setValue( range.getMax() );
+
+		rangeSlider.setRange( valueToPos( range.getMin() ), valueToPos( range.getMax() ) );
+
+		final double frac = Math.max(
+				Math.abs( Math.round( minBound ) - minBound ),
+				Math.abs( Math.round( maxBound ) - maxBound ) );
+		final String format = frac > 0.005 ? "%.2f" : "%.0f";
+		upperBoundLabel.setText( String.format( format, maxBound ) );
+		lowerBoundLabel.setText( String.format( format, minBound ) );
+		this.invalidate();
+
+		blockUpdates = false;
+
+		listeners.list.forEach( ChangeListener::boundedRangeChanged );
+	}
+
+	public BoundedRange getRange()
+	{
+		return range;
+	}
+
+	public Listeners< ChangeListener > changeListeners()
+	{
+		return listeners;
+	}
+
+	public void setPopup( final Supplier< JPopupMenu > popup )
+	{
+		this.popup = popup;
+	}
+
+	public void shrinkBoundsToRange()
+	{
+		updateRange( range.withMinBound( range.getMin() ).withMaxBound( range.getMax() ) );
+	}
+
+	public void setBoundsDialog()
+	{
+		final JPanel panel = new JPanel( new MigLayout( "fillx", "[][grow]", "" ) );
+		final JSpinner minSpinner = new JSpinner( new SpinnerNumberModel( 0.0, 0.0, 1.0, 1.0 ) );
+		final JSpinner maxSpinner = new JSpinner( new SpinnerNumberModel( 0.0, 0.0, 1.0, 1.0 ) );
+		minSpinner.setEditor( new UnboundedNumberEditor( minSpinner ) );
+		maxSpinner.setEditor( new UnboundedNumberEditor( maxSpinner ) );
+		minSpinner.setValue( range.getMinBound() );
+		maxSpinner.setValue( range.getMaxBound() );
+		minSpinner.addChangeListener( e -> {
+			final double value = ( Double ) minSpinner.getValue();
+			if ( value > ( Double ) maxSpinner.getValue() )
+				maxSpinner.setValue( value );
+		} );
+		maxSpinner.addChangeListener( e -> {
+			final double value = ( Double ) maxSpinner.getValue();
+			if ( value < ( Double ) minSpinner.getValue() )
+				minSpinner.setValue( value );
+		} );
+		panel.add( "right", new JLabel( "min" ) );
+		panel.add( "growx, wrap", minSpinner );
+		panel.add( "right", new JLabel( "max" ) );
+		panel.add( "growx", maxSpinner );
+		final int result = JOptionPane.showConfirmDialog( null, panel, "Set Bounds", JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE );
+		if ( result == JOptionPane.YES_OPTION )
+		{
+			final double min = ( Double ) minSpinner.getValue();
+			final double max = ( Double ) maxSpinner.getValue();
+			updateRange( range.withMinBound( min ).withMaxBound( max ) );
+		}
+	}
+}
diff --git a/src/main/java/bdv/ui/convertersetupeditor/ColorEditor.java b/src/main/java/bdv/ui/convertersetupeditor/ColorEditor.java
new file mode 100644
index 0000000000000000000000000000000000000000..21c608418d3a5a098ecd6faeb890bf9915eee9ba
--- /dev/null
+++ b/src/main/java/bdv/ui/convertersetupeditor/ColorEditor.java
@@ -0,0 +1,179 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.ui.convertersetupeditor;
+
+import bdv.tools.brightness.ConverterSetup;
+import bdv.viewer.ConverterSetups;
+import bdv.ui.sourcetable.SourceTable;
+import bdv.ui.sourcegrouptree.SourceGroupTree;
+import bdv.ui.UIUtils;
+import java.awt.Color;
+import java.util.List;
+import java.util.function.Supplier;
+import javax.swing.SwingUtilities;
+import javax.swing.event.TreeModelEvent;
+import javax.swing.event.TreeModelListener;
+import net.imglib2.type.numeric.ARGBType;
+
+class ColorEditor
+{
+	private final Supplier< List< ConverterSetup > > selectedConverterSetups;
+
+	private final ColorPanel colorPanel;
+
+	private final Color equalColor;
+
+	private final Color notEqualColor;
+
+	public ColorEditor(
+			final SourceTable table,
+			final ConverterSetups converterSetups,
+			final ColorPanel colorPanel )
+	{
+		this( table::getSelectedConverterSetups, converterSetups, colorPanel );
+		table.getSelectionModel().addListSelectionListener( e -> updateSelection() );
+	}
+
+	public ColorEditor(
+			final SourceGroupTree tree,
+			final ConverterSetups converterSetups,
+			final ColorPanel colorPanel )
+	{
+		this(
+				() -> converterSetups.getConverterSetups( tree.getSelectedSources() ),
+				converterSetups, colorPanel );
+		tree.getSelectionModel().addTreeSelectionListener( e -> updateSelection() );
+		tree.getModel().addTreeModelListener( new TreeModelListener()
+		{
+			@Override
+			public void treeNodesChanged( final TreeModelEvent e )
+			{
+				updateSelection();
+			}
+
+			@Override
+			public void treeNodesInserted( final TreeModelEvent e )
+			{
+				updateSelection();
+			}
+
+			@Override
+			public void treeNodesRemoved( final TreeModelEvent e )
+			{
+				updateSelection();
+			}
+
+			@Override
+			public void treeStructureChanged( final TreeModelEvent e )
+			{
+				updateSelection();
+			}
+		} );
+	}
+
+	private ColorEditor(
+			final Supplier< List< ConverterSetup > > selectedConverterSetups,
+			final ConverterSetups converterSetups,
+			final ColorPanel colorPanel )
+	{
+		this.selectedConverterSetups = selectedConverterSetups;
+		this.colorPanel = colorPanel;
+
+		colorPanel.changeListeners().add( this::updateConverterSetupColors );
+		converterSetups.listeners().add( s -> updateColorPanel() );
+
+		equalColor = colorPanel.getBackground();
+		notEqualColor = UIUtils.mix( equalColor, Color.red, 0.9 );
+	}
+
+	private boolean blockUpdates = false;
+
+	private List< ConverterSetup > converterSetups;
+
+	private synchronized void updateConverterSetupColors()
+	{
+		if ( blockUpdates || converterSetups == null || converterSetups.isEmpty() )
+			return;
+
+		ARGBType color = colorPanel.getColor();
+
+		for ( final ConverterSetup converterSetup : converterSetups )
+		{
+			if ( converterSetup.supportsColor() )
+				converterSetup.setColor( color );
+		}
+
+		updateColorPanel();
+	}
+
+	private synchronized void updateSelection()
+	{
+		converterSetups = selectedConverterSetups.get();
+		updateColorPanel();
+	}
+
+	private synchronized void updateColorPanel()
+	{
+		if ( converterSetups == null || converterSetups.isEmpty() )
+		{
+			SwingUtilities.invokeLater( () -> {
+				colorPanel.setEnabled( false );
+				colorPanel.setColor( null );
+				colorPanel.setBackground( equalColor );
+			} );
+		}
+		else
+		{
+			ARGBType color = null;
+			boolean allColorsEqual = true;
+			for ( final ConverterSetup converterSetup : converterSetups )
+			{
+				if ( converterSetup.supportsColor() )
+				{
+					if ( color == null )
+						color = converterSetup.getColor();
+					else
+						allColorsEqual &= color.equals( converterSetup.getColor() );
+				}
+			}
+			final ARGBType finalColor = color;
+			final Color bg = allColorsEqual ? equalColor : notEqualColor;
+			SwingUtilities.invokeLater( () -> {
+				synchronized ( ColorEditor.this )
+				{
+					blockUpdates = true;
+					colorPanel.setEnabled( finalColor != null );
+					colorPanel.setColor( finalColor );
+					colorPanel.setBackground( bg );
+					blockUpdates = false;
+				}
+			} );
+		}
+	}
+}
diff --git a/src/main/java/bdv/ui/convertersetupeditor/ColorPanel.java b/src/main/java/bdv/ui/convertersetupeditor/ColorPanel.java
new file mode 100644
index 0000000000000000000000000000000000000000..9b7297ae29085260b53400e8331b4ff0b94f0bfc
--- /dev/null
+++ b/src/main/java/bdv/ui/convertersetupeditor/ColorPanel.java
@@ -0,0 +1,115 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.ui.convertersetupeditor;
+
+import java.awt.Color;
+
+import java.awt.Dimension;
+import javax.swing.JButton;
+import javax.swing.JColorChooser;
+import javax.swing.JPanel;
+
+import net.imglib2.type.numeric.ARGBType;
+import net.miginfocom.swing.MigLayout;
+
+import org.scijava.listeners.Listeners;
+
+import bdv.tools.brightness.ColorIcon;
+
+/**
+ * A {@code JPanel} with a color button (for setting {@code ConverterSetup}
+ * colors).
+ *
+ * @author Tobias Pietzsch
+ */
+class ColorPanel extends JPanel
+{
+	private final JButton colorButton;
+
+	private final ARGBType color = new ARGBType();
+
+	public interface ChangeListener
+	{
+		void colorChanged();
+	}
+
+	private final Listeners.List< ChangeListener > listeners = new Listeners.SynchronizedList<>();
+
+	public ColorPanel()
+	{
+		setLayout( new MigLayout( "ins 0, fillx, filly, hidemode 3", "[grow]", "" ) );
+		colorButton = new JButton();
+		this.add( colorButton, "center" );
+
+		colorButton.addActionListener( e -> chooseColor() );
+
+		colorButton.setBorderPainted( false );
+		colorButton.setFocusPainted( false );
+		colorButton.setContentAreaFilled( false );
+		colorButton.setMinimumSize( new Dimension( 46, 42 ) );
+		colorButton.setPreferredSize( new Dimension( 46, 42 ) );
+		setColor( null );
+	}
+
+	@Override
+	public void setEnabled( final boolean enabled )
+	{
+		super.setEnabled( enabled );
+		if ( colorButton != null )
+			colorButton.setEnabled( enabled );
+	}
+
+	private void chooseColor()
+	{
+		final Color newColor = JColorChooser.showDialog( null, "Set Source Color", new Color( color.get() ) );
+		if ( newColor == null )
+			return;
+		setColor( new ARGBType(  newColor.getRGB() | 0xff000000 ) );
+		listeners.list.forEach( ChangeListener::colorChanged );
+	};
+
+	public Listeners< ChangeListener > changeListeners()
+	{
+		return listeners;
+	}
+
+	public synchronized void setColor( final ARGBType color )
+	{
+		if ( color == null )
+			this.color.set( 0xffaaaaaa );
+		else
+			this.color.set( color );
+		colorButton.setIcon( new ColorIcon( new Color( this.color.get() ), 30, 30, 10, 10, true ) );
+	}
+
+	public ARGBType getColor()
+	{
+		return color.copy();
+	}
+}
diff --git a/src/main/java/bdv/ui/convertersetupeditor/ConverterSetupEditPanel.java b/src/main/java/bdv/ui/convertersetupeditor/ConverterSetupEditPanel.java
new file mode 100644
index 0000000000000000000000000000000000000000..b2b2cb9cca076a92385e740b3d2ff89281bd787a
--- /dev/null
+++ b/src/main/java/bdv/ui/convertersetupeditor/ConverterSetupEditPanel.java
@@ -0,0 +1,82 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.ui.convertersetupeditor;
+
+import bdv.viewer.ConverterSetupBounds;
+import javax.swing.JPanel;
+
+import net.miginfocom.swing.MigLayout;
+
+import bdv.viewer.ConverterSetups;
+import bdv.ui.sourcetable.SourceTable;
+import bdv.ui.sourcegrouptree.SourceGroupTree;
+
+/**
+ * A {@code JPanel} containing a {@link ColorPanel} and a
+ * {@link BoundedRangePanel}. It can be constructed for a {@link SourceTable} or
+ * a {@link SourceGroupTree}, and will be set up to edit the selected
+ * source/groups respectively.
+ *
+ * @author Tobias Pietzsch
+ */
+public class ConverterSetupEditPanel extends JPanel
+{
+	private final ColorPanel colorPanel;
+
+	private final BoundedRangePanel rangePanel;
+
+	public ConverterSetupEditPanel(
+			final SourceGroupTree tree,
+			final ConverterSetups converterSetups )
+	{
+		this();
+		new BoundedRangeEditor( tree, converterSetups, rangePanel, converterSetups.getBounds() );
+		new ColorEditor( tree, converterSetups, colorPanel );
+	}
+
+	public ConverterSetupEditPanel(
+			final SourceTable table,
+			final ConverterSetups converterSetups )
+	{
+		this();
+		new BoundedRangeEditor( table, converterSetups, rangePanel, converterSetups.getBounds() );
+		new ColorEditor( table, converterSetups, colorPanel );
+	}
+
+	public ConverterSetupEditPanel()
+	{
+		super( new MigLayout( "ins 0, fillx, hidemode 3", "[]0[grow]", "" ) );
+		colorPanel = new ColorPanel();
+		rangePanel = new BoundedRangePanel();
+
+		( ( MigLayout ) rangePanel.getLayout() ).setLayoutConstraints( "ins 5 5 5 10, fillx, filly, hidemode 3" );
+		add( colorPanel, "growy" );
+		add( rangePanel, "grow" );
+	}
+}
diff --git a/src/main/java/bdv/ui/rangeslider/RangeSlider.java b/src/main/java/bdv/ui/rangeslider/RangeSlider.java
new file mode 100644
index 0000000000000000000000000000000000000000..68fae9ebc158d0b6525aed41fdc8194ff8a04e49
--- /dev/null
+++ b/src/main/java/bdv/ui/rangeslider/RangeSlider.java
@@ -0,0 +1,184 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.ui.rangeslider;
+
+import javax.swing.JSlider;
+
+/**
+ * An extension of JSlider to select a range of values using two thumb controls.
+ * The thumb controls are used to select the lower and upper value of a range
+ * with predetermined minimum and maximum values.
+ * <p>
+ * Note that RangeSlider makes use of the default BoundedRangeModel, which
+ * supports an inner range defined by a value and an extent. The upper value
+ * returned by RangeSlider is simply the lower value plus the extent.
+ * <p>
+ * This is copied from https://github.com/ernieyu/Swing-range-slider with the
+ * following changes:
+ * <ul>
+ * <li>The slider thumbs push each other. That is, the upper thumb can be
+ * dragged below the lower thumb, and it will drag the lower thumb with it, and
+ * vice versa</li>
+ * <li>The {@link #setRange(int, int)} method was added, which sets both the
+ * lower and upper values simultaneously.</li>
+ * </ul>
+ * <p>
+ *
+ * <pre>
+ * =============================================================================
+ *
+ * The MIT License Copyright (c) 2010 Ernest Yu. All rights reserved.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ * </pre>
+ */
+public class RangeSlider extends JSlider
+{
+	private static final long serialVersionUID = 1L;
+
+	/**
+	 * Constructs a RangeSlider with default minimum and maximum values of 0 and
+	 * 100.
+	 */
+	public RangeSlider()
+	{
+		initSlider();
+	}
+
+	/**
+	 * Constructs a RangeSlider with the specified default minimum and maximum
+	 * values.
+	 */
+	public RangeSlider( final int min, final int max )
+	{
+		super( min, max );
+		initSlider();
+	}
+
+	/**
+	 * Initializes the slider by setting default properties.
+	 */
+	private void initSlider()
+	{
+		setOrientation( HORIZONTAL );
+	}
+
+	/**
+	 * Overrides the superclass method to install the UI delegate to draw two
+	 * thumbs.
+	 */
+	@Override
+	public void updateUI()
+	{
+		setUI( new RangeSliderUI( this ) );
+		// Update UI for slider labels. This must be called after updating the
+		// UI of the slider. Refer to JSlider.updateUI().
+		updateLabelUIs();
+	}
+
+	/**
+	 * Returns the lower value in the range.
+	 */
+	@Override
+	public int getValue()
+	{
+		return super.getValue();
+	}
+
+	/**
+	 * Sets the lower value in the range.
+	 */
+	@Override
+	public void setValue( final int value )
+	{
+		final int oldValue = getValue();
+		if ( oldValue == value )
+		{ return; }
+
+		// Compute new value and extent to maintain upper value.
+		final int oldExtent = getExtent();
+		final int newValue = Math.min( Math.max( getMinimum(), value ), oldValue + oldExtent );
+		final int newExtent = oldExtent + oldValue - newValue;
+
+		// Set new value and extent, and fire a single change event.
+		getModel().setRangeProperties( newValue, newExtent, getMinimum(),
+				getMaximum(), getValueIsAdjusting() );
+	}
+
+	/**
+	 * Returns the upper value in the range.
+	 */
+	public int getUpperValue()
+	{
+		return getValue() + getExtent();
+	}
+
+	/**
+	 * Sets the upper value in the range.
+	 */
+	public void setUpperValue( final int value )
+	{
+		// Compute new extent.
+		final int lowerValue = getValue();
+		final int newExtent = Math.min( Math.max( 0, value - lowerValue ), getMaximum() - lowerValue );
+
+		// Set extent to set upper value.
+		setExtent( newExtent );
+	}
+
+	/**
+	 * Sets both the lower and upper values in the range.
+	 */
+	public void setRange( final int lower, final int upper )
+	{
+		final int newValue = Math.max( getMinimum(), Math.min( getMaximum(), lower ) );
+		final int newUpper = Math.max( newValue, Math.min( getMaximum(), upper ) );
+		final int newExtent = newUpper - newValue;
+
+		// Set new value and extent, and fire a single change event.
+		getModel().setRangeProperties( newValue, newExtent, getMinimum(),
+				getMaximum(), getValueIsAdjusting() );
+	}
+}
diff --git a/src/main/java/bdv/ui/rangeslider/RangeSliderUI.java b/src/main/java/bdv/ui/rangeslider/RangeSliderUI.java
new file mode 100644
index 0000000000000000000000000000000000000000..626b2bbfe68e1ee13c771f476df2ce21bc3463bb
--- /dev/null
+++ b/src/main/java/bdv/ui/rangeslider/RangeSliderUI.java
@@ -0,0 +1,752 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.ui.rangeslider;
+
+import java.awt.Color;
+import java.awt.Dimension;
+import java.awt.Graphics;
+import java.awt.Graphics2D;
+import java.awt.Rectangle;
+import java.awt.RenderingHints;
+import java.awt.Shape;
+import java.awt.event.MouseEvent;
+import java.awt.geom.Ellipse2D;
+
+import javax.swing.JComponent;
+import javax.swing.JSlider;
+import javax.swing.SwingUtilities;
+import javax.swing.event.ChangeEvent;
+import javax.swing.event.ChangeListener;
+import javax.swing.plaf.basic.BasicSliderUI;
+
+/**
+ * UI delegate for the RangeSlider component. RangeSliderUI paints two thumbs,
+ * one for the lower value and one for the upper value.
+ * <p>
+ * This is copied from https://github.com/ernieyu/Swing-range-slider with the
+ * following changes:
+ * <ul>
+ * <li>The slider thumbs push each other. That is, the upper thumb can be
+ * dragged below the lower thumb, and it will drag the lower thumb with it, and
+ * vice versa</li>
+ * <li>The {@link RangeSlider#setRange(int, int)} method was added, to set both
+ * the lower and upper values simultaneously.</li>
+ * </ul>
+ * <p>
+ *
+ * <pre>
+ * =============================================================================
+ *
+ * The MIT License Copyright (c) 2010 Ernest Yu. All rights reserved.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ * </pre>
+ */
+class RangeSliderUI extends BasicSliderUI
+{
+	/**
+	 * Color of selected range.
+	 */
+	private final Color rangeColor = Color.gray;
+
+	/**
+	 * Location and size of thumb for upper value.
+	 */
+	private Rectangle upperThumbRect;
+
+	/**
+	 * Indicator that determines whether upper thumb is selected.
+	 */
+	private boolean upperThumbSelected;
+
+	/**
+	 * Indicator that determines whether lower thumb is being dragged.
+	 */
+	private transient boolean lowerDragging;
+
+	/**
+	 * Indicator that determines whether upper thumb is being dragged.
+	 */
+	private transient boolean upperDragging;
+
+	/**
+	 * Constructs a RangeSliderUI for the specified slider component.
+	 *
+	 * @param b
+	 *     RangeSlider
+	 */
+	public RangeSliderUI( final RangeSlider b )
+	{
+		super( b );
+	}
+
+	/**
+	 * Installs this UI delegate on the specified component.
+	 */
+	@Override
+	public void installUI( final JComponent c )
+	{
+		upperThumbRect = new Rectangle();
+		super.installUI( c );
+	}
+
+	/**
+	 * Creates a listener to handle track events in the specified slider.
+	 */
+	@Override
+	protected TrackListener createTrackListener( final JSlider slider )
+	{
+		return new RangeTrackListener();
+	}
+
+	/**
+	 * Creates a listener to handle change events in the specified slider.
+	 */
+	@Override
+	protected ChangeListener createChangeListener( final JSlider slider )
+	{
+		return new ChangeHandler();
+	}
+
+	/**
+	 * Updates the dimensions for both thumbs.
+	 */
+	@Override
+	protected void calculateThumbSize()
+	{
+		// Call superclass method for lower thumb size.
+		super.calculateThumbSize();
+
+		// Set upper thumb size.
+		upperThumbRect.setSize( thumbRect.width, thumbRect.height );
+	}
+
+	/**
+	 * Updates the locations for both thumbs.
+	 */
+	@Override
+	protected void calculateThumbLocation()
+	{
+		// Call superclass method for lower thumb location.
+		super.calculateThumbLocation();
+
+		// Adjust upper value to snap to ticks if necessary.
+		if ( slider.getSnapToTicks() )
+		{
+			final int upperValue = slider.getValue() + slider.getExtent();
+			int snappedValue = upperValue;
+			final int majorTickSpacing = slider.getMajorTickSpacing();
+			final int minorTickSpacing = slider.getMinorTickSpacing();
+			int tickSpacing = 0;
+
+			if ( minorTickSpacing > 0 )
+			{
+				tickSpacing = minorTickSpacing;
+			}
+			else if ( majorTickSpacing > 0 )
+			{
+				tickSpacing = majorTickSpacing;
+			}
+
+			if ( tickSpacing != 0 )
+			{
+				// If it's not on a tick, change the value
+				if ( ( upperValue - slider.getMinimum() ) % tickSpacing != 0 )
+				{
+					final float temp = ( float ) ( upperValue - slider.getMinimum() ) / ( float ) tickSpacing;
+					final int whichTick = Math.round( temp );
+					snappedValue = slider.getMinimum() + ( whichTick * tickSpacing );
+				}
+
+				if ( snappedValue != upperValue )
+				{
+					slider.setExtent( snappedValue - slider.getValue() );
+				}
+			}
+		}
+
+		// Calculate upper thumb location. The thumb is centered over its
+		// value on the track.
+		if ( slider.getOrientation() == JSlider.HORIZONTAL )
+		{
+			final int upperPosition = xPositionForValue( slider.getValue() + slider.getExtent() );
+			upperThumbRect.x = upperPosition - ( upperThumbRect.width / 2 );
+			upperThumbRect.y = trackRect.y;
+
+		}
+		else
+		{
+			final int upperPosition = yPositionForValue( slider.getValue() + slider.getExtent() );
+			upperThumbRect.x = trackRect.x;
+			upperThumbRect.y = upperPosition - ( upperThumbRect.height / 2 );
+		}
+	}
+
+	/**
+	 * Returns the size of a thumb.
+	 */
+	@Override
+	protected Dimension getThumbSize()
+	{
+		return new Dimension( 12, 12 );
+	}
+
+	/**
+	 * Paints the slider. The selected thumb is always painted on top of the
+	 * other thumb.
+	 */
+	@Override
+	public void paint( final Graphics g, final JComponent c )
+	{
+		super.paint( g, c );
+
+		final Rectangle clipRect = g.getClipBounds();
+		if ( upperThumbSelected )
+		{
+			// Paint lower thumb first, then upper thumb.
+			if ( clipRect.intersects( thumbRect ) )
+			{
+				paintLowerThumb( g );
+			}
+			if ( clipRect.intersects( upperThumbRect ) )
+			{
+				paintUpperThumb( g );
+			}
+
+		}
+		else
+		{
+			// Paint upper thumb first, then lower thumb.
+			if ( clipRect.intersects( upperThumbRect ) )
+			{
+				paintUpperThumb( g );
+			}
+			if ( clipRect.intersects( thumbRect ) )
+			{
+				paintLowerThumb( g );
+			}
+		}
+	}
+
+	/**
+	 * Paints the track.
+	 */
+	@Override
+	public void paintTrack( final Graphics g )
+	{
+		// Draw track.
+		super.paintTrack( g );
+
+		final Rectangle trackBounds = trackRect;
+
+		if ( slider.getOrientation() == JSlider.HORIZONTAL )
+		{
+			// Determine position of selected range by moving from the middle
+			// of one thumb to the other.
+			final int lowerX = thumbRect.x + ( thumbRect.width / 2 );
+			final int upperX = upperThumbRect.x + ( upperThumbRect.width / 2 );
+
+			// Determine track position.
+			final int cy = ( trackBounds.height / 2 ) - 2;
+
+			// Save color and shift position.
+			final Color oldColor = g.getColor();
+			g.translate( trackBounds.x, trackBounds.y + cy );
+
+			// Draw selected range.
+			g.setColor( getSliderColor() );
+			for ( int y = 0; y <= 2; y++ )
+			{
+				g.drawLine( lowerX - trackBounds.x, y, upperX - trackBounds.x, y );
+			}
+
+			// Restore position and color.
+			g.translate( -trackBounds.x, -( trackBounds.y + cy ) );
+			g.setColor( oldColor );
+
+		}
+		else
+		{
+			// Determine position of selected range by moving from the middle
+			// of one thumb to the other.
+			final int lowerY = thumbRect.x + ( thumbRect.width / 2 );
+			final int upperY = upperThumbRect.x + ( upperThumbRect.width / 2 );
+
+			// Determine track position.
+			final int cx = ( trackBounds.width / 2 ) - 2;
+
+			// Save color and shift position.
+			final Color oldColor = g.getColor();
+			g.translate( trackBounds.x + cx, trackBounds.y );
+
+			// Draw selected range.
+			g.setColor( getSliderColor() );
+			for ( int x = 0; x <= 2; x++ )
+			{
+				g.drawLine( x, lowerY - trackBounds.y, x, upperY - trackBounds.y );
+			}
+
+			// Restore position and color.
+			g.translate( -( trackBounds.x + cx ), -trackBounds.y );
+			g.setColor( oldColor );
+		}
+	}
+
+	/**
+	 * Overrides superclass method to do nothing. Thumb painting is handled
+	 * within the <code>paint()</code> method.
+	 */
+	@Override
+	public void paintThumb( final Graphics g )
+	{
+		// Do nothing.
+	}
+
+	private Color getSliderFillColor()
+	{
+		return slider.isEnabled() ? Color.lightGray : Color.white;
+	}
+
+	private Color getSliderColor()
+	{
+		return slider.isEnabled() ? Color.darkGray : Color.lightGray;
+	}
+
+	/**
+	 * Paints the thumb for the lower value using the specified graphics object.
+	 */
+	private void paintLowerThumb( final Graphics g )
+	{
+		final Rectangle knobBounds = thumbRect;
+		final int w = knobBounds.width;
+		final int h = knobBounds.height;
+
+		// Create graphics copy.
+		final Graphics2D g2d = ( Graphics2D ) g.create();
+
+		// Create default thumb shape.
+		final Shape thumbShape = createThumbShape( w - 1, h - 1 );
+
+		// Draw thumb.
+		g2d.setRenderingHint( RenderingHints.KEY_ANTIALIASING,
+				RenderingHints.VALUE_ANTIALIAS_ON );
+		g2d.translate( knobBounds.x, knobBounds.y );
+
+		final Color sliderFillColor = getSliderFillColor();
+		g2d.setColor( sliderFillColor );
+		g2d.fill( thumbShape );
+
+		final Color sliderColor = getSliderColor();
+		g2d.setColor( sliderColor );
+		g2d.draw( thumbShape );
+
+		// Dispose graphics.
+		g2d.dispose();
+	}
+
+	/**
+	 * Paints the thumb for the upper value using the specified graphics object.
+	 */
+	private void paintUpperThumb( final Graphics g )
+	{
+		final Rectangle knobBounds = upperThumbRect;
+		final int w = knobBounds.width;
+		final int h = knobBounds.height;
+
+		// Create graphics copy.
+		final Graphics2D g2d = ( Graphics2D ) g.create();
+
+		// Create default thumb shape.
+		final Shape thumbShape = createThumbShape( w - 1, h - 1 );
+
+		// Draw thumb.
+		g2d.setRenderingHint( RenderingHints.KEY_ANTIALIASING,
+				RenderingHints.VALUE_ANTIALIAS_ON );
+		g2d.translate( knobBounds.x, knobBounds.y );
+
+		final Color sliderFillColor = getSliderFillColor();
+		g2d.setColor( sliderFillColor );
+		g2d.fill( thumbShape );
+
+		final Color sliderColor = getSliderColor();
+		g2d.setColor( sliderColor );
+		g2d.draw( thumbShape );
+
+		// Dispose graphics.
+		g2d.dispose();
+	}
+
+	/**
+	 * Returns a Shape representing a thumb.
+	 */
+	private Shape createThumbShape( final int width, final int height )
+	{
+		// Use circular shape.
+		final Ellipse2D shape = new Ellipse2D.Double( 0, 0, width, height );
+		return shape;
+	}
+
+	/**
+	 * Sets the location of the upper thumb, and repaints the slider. This is
+	 * called when the upper thumb is dragged to repaint the slider. The
+	 * <code>setThumbLocation()</code> method performs the same task for the
+	 * lower thumb.
+	 */
+	private void setUpperThumbLocation( final int x, final int y )
+	{
+		final Rectangle upperUnionRect = new Rectangle();
+		upperUnionRect.setBounds( upperThumbRect );
+
+		upperThumbRect.setLocation( x, y );
+
+		SwingUtilities.computeUnion( upperThumbRect.x, upperThumbRect.y, upperThumbRect.width, upperThumbRect.height, upperUnionRect );
+		slider.repaint( upperUnionRect.x, upperUnionRect.y, upperUnionRect.width, upperUnionRect.height );
+	}
+
+	/**
+	 * Moves the selected thumb in the specified direction by a block increment.
+	 * This method is called when the user presses the Page Up or Down keys.
+	 */
+	@Override
+	public void scrollByBlock( final int direction )
+	{
+		synchronized ( slider )
+		{
+			int blockIncrement = ( slider.getMaximum() - slider.getMinimum() ) / 10;
+			if ( blockIncrement <= 0 && slider.getMaximum() > slider.getMinimum() )
+			{
+				blockIncrement = 1;
+			}
+			final int delta = blockIncrement * ( ( direction > 0 ) ? POSITIVE_SCROLL : NEGATIVE_SCROLL );
+
+			if ( upperThumbSelected )
+			{
+				final int oldValue = ( ( RangeSlider ) slider ).getUpperValue();
+				( ( RangeSlider ) slider ).setUpperValue( oldValue + delta );
+			}
+			else
+			{
+				final int oldValue = slider.getValue();
+				slider.setValue( oldValue + delta );
+			}
+		}
+	}
+
+	/**
+	 * Moves the selected thumb in the specified direction by a unit increment.
+	 * This method is called when the user presses one of the arrow keys.
+	 */
+	@Override
+	public void scrollByUnit( final int direction )
+	{
+		synchronized ( slider )
+		{
+			final int delta = 1 * ( ( direction > 0 ) ? POSITIVE_SCROLL : NEGATIVE_SCROLL );
+
+			if ( upperThumbSelected )
+			{
+				final int oldValue = ( ( RangeSlider ) slider ).getUpperValue();
+				( ( RangeSlider ) slider ).setUpperValue( oldValue + delta );
+			}
+			else
+			{
+				final int oldValue = slider.getValue();
+				slider.setValue( oldValue + delta );
+			}
+		}
+	}
+
+	/**
+	 * Listener to handle model change events. This calculates the thumb
+	 * locations and repaints the slider if the value change is not caused by
+	 * dragging a thumb.
+	 */
+	public class ChangeHandler implements ChangeListener
+	{
+		@Override
+		public void stateChanged( final ChangeEvent arg0 )
+		{
+			if ( !lowerDragging && !upperDragging )
+			{
+				calculateThumbLocation();
+				slider.repaint();
+			}
+		}
+	}
+
+	/**
+	 * Listener to handle mouse movements in the slider track.
+	 */
+	public class RangeTrackListener extends TrackListener
+	{
+
+		@Override
+		public void mousePressed( final MouseEvent e )
+		{
+			if ( !slider.isEnabled() )
+			{ return; }
+
+			currentMouseX = e.getX();
+			currentMouseY = e.getY();
+
+			if ( slider.isRequestFocusEnabled() )
+			{
+				slider.requestFocus();
+			}
+
+			// Determine which thumb is pressed. If the upper thumb is
+			// selected (last one dragged), then check its position first;
+			// otherwise check the position of the lower thumb first.
+			boolean lowerPressed = false;
+			boolean upperPressed = false;
+			if ( upperThumbSelected || slider.getMinimum() == slider.getValue() )
+			{
+				if ( upperThumbRect.contains( currentMouseX, currentMouseY ) )
+				{
+					upperPressed = true;
+				}
+				else if ( thumbRect.contains( currentMouseX, currentMouseY ) )
+				{
+					lowerPressed = true;
+				}
+			}
+			else
+			{
+				if ( thumbRect.contains( currentMouseX, currentMouseY ) )
+				{
+					lowerPressed = true;
+				}
+				else if ( upperThumbRect.contains( currentMouseX, currentMouseY ) )
+				{
+					upperPressed = true;
+				}
+			}
+
+			// Handle lower thumb pressed.
+			if ( lowerPressed )
+			{
+				switch ( slider.getOrientation() )
+				{
+				case JSlider.VERTICAL:
+					offset = currentMouseY - thumbRect.y;
+					break;
+				case JSlider.HORIZONTAL:
+					offset = currentMouseX - thumbRect.x;
+					break;
+				}
+				upperThumbSelected = false;
+				lowerDragging = true;
+				return;
+			}
+			lowerDragging = false;
+
+			// Handle upper thumb pressed.
+			if ( upperPressed )
+			{
+				switch ( slider.getOrientation() )
+				{
+				case JSlider.VERTICAL:
+					offset = currentMouseY - upperThumbRect.y;
+					break;
+				case JSlider.HORIZONTAL:
+					offset = currentMouseX - upperThumbRect.x;
+					break;
+				}
+				upperThumbSelected = true;
+				upperDragging = true;
+				return;
+			}
+			upperDragging = false;
+		}
+
+		@Override
+		public void mouseReleased( final MouseEvent e )
+		{
+			lowerDragging = false;
+			upperDragging = false;
+			slider.setValueIsAdjusting( false );
+			super.mouseReleased( e );
+		}
+
+		@Override
+		public void mouseDragged( final MouseEvent e )
+		{
+			if ( !slider.isEnabled() )
+			{ return; }
+
+			currentMouseX = e.getX();
+			currentMouseY = e.getY();
+
+			if ( lowerDragging )
+			{
+				slider.setValueIsAdjusting( true );
+				moveLowerThumb();
+
+			}
+			else if ( upperDragging )
+			{
+				slider.setValueIsAdjusting( true );
+				moveUpperThumb();
+			}
+		}
+
+		@Override
+		public boolean shouldScroll( final int direction )
+		{
+			return false;
+		}
+
+		/**
+		 * Moves the location of the lower thumb, and sets its corresponding
+		 * value in the slider.
+		 */
+		private void moveLowerThumb()
+		{
+			int thumbMiddle = 0;
+
+			final RangeSlider slider = ( RangeSlider ) RangeSliderUI.this.slider;
+			if ( slider.getOrientation() == JSlider.VERTICAL )
+			{
+				final int halfThumbHeight = thumbRect.height / 2;
+				int thumbTop = currentMouseY - offset;
+				final int trackTop = trackRect.y;
+				final int trackBottom = trackRect.y + ( trackRect.height - 1 );
+
+				thumbTop = Math.max( thumbTop, trackTop - halfThumbHeight );
+				thumbTop = Math.min( thumbTop, trackBottom - halfThumbHeight );
+
+				setThumbLocation( thumbRect.x, thumbTop );
+
+				// Update slider value.
+				thumbMiddle = thumbTop + halfThumbHeight;
+
+				final int lower = valueForYPosition( thumbMiddle );
+				final int upper = Math.max( lower, slider.getUpperValue() );
+
+				final int upperThumbTop = yPositionForValue( upper ) - halfThumbHeight;
+				setUpperThumbLocation( thumbRect.x, upperThumbTop );
+
+				slider.setRange( lower, upper );
+			}
+			else if ( slider.getOrientation() == JSlider.HORIZONTAL )
+			{
+				final int halfThumbWidth = thumbRect.width / 2;
+				int thumbLeft = currentMouseX - offset;
+				final int trackLeft = trackRect.x;
+				final int trackRight = trackRect.x + ( trackRect.width - 1 );
+
+				thumbLeft = Math.max( thumbLeft, trackLeft - halfThumbWidth );
+				thumbLeft = Math.min( thumbLeft, trackRight - halfThumbWidth );
+
+				setThumbLocation( thumbLeft, thumbRect.y );
+
+				// Update slider range.
+				thumbMiddle = thumbLeft + halfThumbWidth;
+				final int lower = valueForXPosition( thumbMiddle );
+				final int upper = Math.max( lower, slider.getUpperValue() );
+
+				final int upperThumbLeft = xPositionForValue( upper ) - halfThumbWidth;
+				setUpperThumbLocation( upperThumbLeft, thumbRect.y );
+
+				slider.setRange( lower, upper );
+			}
+		}
+
+		/**
+		 * Moves the location of the upper thumb, and sets its corresponding
+		 * value in the slider.
+		 */
+		private void moveUpperThumb()
+		{
+			int thumbMiddle = 0;
+
+			final RangeSlider slider = ( RangeSlider ) RangeSliderUI.this.slider;
+			if ( slider.getOrientation() == JSlider.VERTICAL )
+			{
+				final int halfThumbHeight = thumbRect.height / 2;
+				int thumbTop = currentMouseY - offset;
+				final int trackTop = trackRect.y;
+				final int trackBottom = trackRect.y + ( trackRect.height - 1 );
+
+				thumbTop = Math.max( thumbTop, trackTop - halfThumbHeight );
+				thumbTop = Math.min( thumbTop, trackBottom - halfThumbHeight );
+
+				setUpperThumbLocation( thumbRect.x, thumbTop );
+
+				// Update slider extent.
+				thumbMiddle = thumbTop + halfThumbHeight;
+				final int upper = valueForYPosition( thumbMiddle );
+				final int lower = Math.min( upper, slider.getValue() );
+
+				final int lowerThumbTop = yPositionForValue( lower ) - halfThumbHeight;
+				setThumbLocation( thumbRect.x, lowerThumbTop );
+
+				slider.setRange( lower, upper );
+			}
+			else if ( slider.getOrientation() == JSlider.HORIZONTAL )
+			{
+				final int halfThumbWidth = thumbRect.width / 2;
+				int thumbLeft = currentMouseX - offset;
+				final int trackLeft = trackRect.x;
+				final int trackRight = trackRect.x + ( trackRect.width - 1 );
+
+				thumbLeft = Math.max( thumbLeft, trackLeft - halfThumbWidth );
+				thumbLeft = Math.min( thumbLeft, trackRight - halfThumbWidth );
+
+				setUpperThumbLocation( thumbLeft, thumbRect.y );
+
+				// Update slider range.
+				thumbMiddle = thumbLeft + halfThumbWidth;
+				final int upper = valueForXPosition( thumbMiddle );
+				final int lower = Math.min( upper, slider.getValue() );
+
+				final int lowerThumbLeft = xPositionForValue( lower ) - halfThumbWidth;
+				setThumbLocation( lowerThumbLeft, thumbRect.y );
+
+				slider.setRange( lower, upper );
+			}
+		}
+	}
+}
diff --git a/src/main/java/bdv/ui/sourcegrouptree/SourceGroupEditor.java b/src/main/java/bdv/ui/sourcegrouptree/SourceGroupEditor.java
new file mode 100644
index 0000000000000000000000000000000000000000..c0bb8b041e24e3a272229fd1ed18d6acad869361
--- /dev/null
+++ b/src/main/java/bdv/ui/sourcegrouptree/SourceGroupEditor.java
@@ -0,0 +1,89 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.ui.sourcegrouptree;
+
+import java.awt.Component;
+import java.awt.Graphics;
+import javax.swing.Icon;
+import javax.swing.JTree;
+import javax.swing.tree.DefaultTreeCellEditor;
+import javax.swing.tree.DefaultTreeCellRenderer;
+
+/**
+ * @author Tobias Pietzsch
+ */
+class SourceGroupEditor extends DefaultTreeCellEditor
+{
+	private final SourceGroupTreeCellRenderer sourceGroupRenderer;
+
+	public SourceGroupEditor( final JTree tree, final SourceGroupTreeCellRenderer sourceGroupRenderer )
+	{
+		// super class uses the DefaultTreeCellRenderer for font, icons, preferred size, etc
+		super( tree, new DefaultTreeCellRenderer() );
+		this.sourceGroupRenderer = sourceGroupRenderer;
+	}
+
+	// Overridden because we don't want to start the editing timer at all
+	@Override
+	protected void startEditingTimer()
+	{
+	}
+
+	@Override
+	protected void determineOffset( final JTree tree, final Object value, final boolean isSelected, final boolean expanded, final boolean leaf, final int row )
+	{
+		editingIcon = emptyIcon;
+		offset = sourceGroupRenderer.determineOffset( value );
+	}
+
+	private final Icon emptyIcon = new Icon()
+	{
+		private int iconHeight = -1;
+
+		@Override
+		public void paintIcon( final Component c, final Graphics g, final int x, final int y )
+		{
+		}
+
+		@Override
+		public int getIconWidth()
+		{
+			return 1;
+		}
+
+		@Override
+		public int getIconHeight()
+		{
+			if ( iconHeight < 0 && renderer != null )
+				iconHeight = renderer.getDefaultLeafIcon().getIconHeight();
+			return iconHeight < 0 ? 1 : iconHeight;
+		}
+	};
+
+}
diff --git a/src/main/java/bdv/ui/sourcegrouptree/SourceGroupTree.java b/src/main/java/bdv/ui/sourcegrouptree/SourceGroupTree.java
new file mode 100644
index 0000000000000000000000000000000000000000..0629e427f920ca31e7637731059c5d28be7ec6d5
--- /dev/null
+++ b/src/main/java/bdv/ui/sourcegrouptree/SourceGroupTree.java
@@ -0,0 +1,456 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.ui.sourcegrouptree;
+
+import bdv.ui.SourcesTransferable;
+import bdv.ui.UIUtils;
+import bdv.ui.sourcegrouptree.SourceGroupTreeModel.GroupModel;
+import bdv.ui.sourcegrouptree.SourceGroupTreeModel.SourceModel;
+import bdv.viewer.SourceAndConverter;
+import bdv.viewer.SourceGroup;
+import bdv.viewer.ViewerState;
+import java.awt.Color;
+import java.awt.Component;
+import java.awt.Graphics;
+import java.awt.Point;
+import java.awt.Rectangle;
+import java.awt.datatransfer.Transferable;
+import java.awt.event.InputEvent;
+import java.awt.event.MouseEvent;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javax.swing.JComponent;
+import javax.swing.JTree;
+import javax.swing.SwingUtilities;
+import javax.swing.TransferHandler;
+import javax.swing.tree.TreePath;
+import org.scijava.ui.behaviour.io.InputTriggerConfig;
+import org.scijava.ui.behaviour.util.Actions;
+import org.scijava.ui.behaviour.util.InputActionBindings;
+
+/**
+ * A {@code JTree} that shows the source-groups of a {@link ViewerState} and
+ * allows to modify activeness and currentness, to add/remove sources to/from
+ * groups, and to add/remove/rename groups.
+ *
+ * @author Tobias Pietzsch
+ */
+public class SourceGroupTree extends JTree
+{
+	private final ViewerState state;
+
+	private final SourceGroupTreeModel model;
+
+	private final SourceGroupTreeCellRenderer renderer;
+
+	private final SourceGroupEditor editor;
+
+	private final Color focusedSelectionBg;
+	private final Color unfocusedSelectionBg;
+
+	public SourceGroupTree( final ViewerState state )
+	{
+		this( state, new InputTriggerConfig() );
+	}
+
+	public SourceGroupTree( final ViewerState state, final InputTriggerConfig inputTriggerConfig )
+	{
+		this.state = state;
+		model = new SourceGroupTreeModel( state );
+		setModel( model );
+
+		renderer = new SourceGroupTreeCellRenderer();
+		setCellRenderer( renderer );
+
+		editor = new SourceGroupEditor( this, renderer );
+		setCellEditor( editor );
+
+		setOpaque( false );
+
+		setTransferHandler( new SourceGroupTreeTransferHandler( this, state ) );
+
+		this.installActions( inputTriggerConfig );
+
+		focusedSelectionBg = renderer.getBackgroundSelectionColor();
+		unfocusedSelectionBg = UIUtils.mix( focusedSelectionBg, getBackground(), 0.8 );
+	}
+
+	public void setSelectionBackground( final boolean hasFocus )
+	{
+		renderer.setBackgroundSelectionColor( hasFocus ? focusedSelectionBg : unfocusedSelectionBg );
+		this.repaint();
+	}
+
+	/**
+	 * Get list of sources in selected groups.
+	 *
+	 * TODO how should they be ordered?
+	 */
+	public List< SourceAndConverter< ? > > getSelectedSources()
+	{
+		final List< SourceAndConverter< ? > > sources = new ArrayList<>();
+		for ( final SourceGroup group : getSelectedGroups() )
+			for ( final SourceAndConverter< ? > source : state.getSourcesInGroup( group ) )
+				if ( !sources.contains( source ) )
+					sources.add( source );
+		sources.sort( state.sourceOrder() );
+		return sources;
+	}
+
+	private void installActions( final InputTriggerConfig inputTriggerConfig )
+	{
+		final InputActionBindings keybindings = InputActionBindings.installNewBindings( this, JComponent.WHEN_FOCUSED, false );
+		final Actions actions = new Actions( inputTriggerConfig, "bdv" );
+		actions.install( keybindings, "source groups tree" );
+		actions.runnableAction( () -> toggleSelectedActive(), "toggle active", "A" );
+		actions.runnableAction( () -> makeSelectedActive( true ), "set active", "not mapped" );
+		actions.runnableAction( () -> makeSelectedActive( false ), "set inactive", "not mapped" );
+		actions.runnableAction( () -> cycleSelectedCurrent(), "cycle current", "C" );
+		actions.runnableAction( () -> removeSelected(), "remove sources or groups", "DELETE", "BACK_SPACE" );
+		actions.runnableAction( () -> editGroupName(), "edit name", "ENTER" );
+	}
+
+	private void makeSelectedActive( final boolean active )
+	{
+		final List< SourceGroup > selectedGroups = getSelectedGroups();
+		state.setGroupsActive( selectedGroups, active );
+	}
+
+	private void toggleSelectedActive()
+	{
+		final List< SourceGroup > selectedGroups = getSelectedGroups();
+		if ( !selectedGroups.isEmpty() )
+			state.setGroupsActive( selectedGroups, !state.isGroupActive( selectedGroups.get( 0 ) ) );
+	}
+
+	private void cycleSelectedCurrent()
+	{
+		final List< SourceGroup > selectedGroups = getSelectedGroups();
+		if ( !selectedGroups.isEmpty() )
+		{
+			final SourceGroup current = state.getCurrentGroup();
+			final int i = ( selectedGroups.indexOf( current ) + 1 ) % selectedGroups.size();
+			state.setCurrentGroup( selectedGroups.get( i ) );
+		}
+	}
+
+	private void removeSelected()
+	{
+		final Map< SourceGroup, List< SourceAndConverter< ? > > > groupToSelectedSources = getSelectedSourcesInGroups();
+		final List< SourceGroup > selectedGroups = getSelectedGroups();
+
+		for ( final SourceGroup group : selectedGroups )
+			groupToSelectedSources.remove( group );
+		groupToSelectedSources.forEach( ( group, sources ) -> state.removeSourcesFromGroup( sources, group ) );
+
+		state.removeGroups( selectedGroups );
+	}
+
+	private void editGroupName()
+	{
+		final TreePath path = this.getLeadSelectionPath();
+		if ( path != null )
+		{
+			final Object obj = path.getLastPathComponent();
+			if ( obj instanceof GroupModel )
+				startEditingAtPath( path );
+		}
+	}
+
+	private List< SourceGroup > getSelectedGroups()
+	{
+		final List< SourceGroup > selectedGroups = new ArrayList<>();
+		final TreePath[] selectionPaths = getSelectionPaths();
+		if ( selectionPaths != null )
+			for ( final TreePath path : selectionPaths )
+			{
+				final Object obj = path.getLastPathComponent();
+				if ( obj instanceof GroupModel )
+					selectedGroups.add( ( ( GroupModel ) obj ).getGroup() );
+			}
+		return selectedGroups;
+	}
+
+	/**
+	 * Get all sources that are directly selected in the tree.
+	 *
+	 * @return map from group to list of directly selected sources under the group
+	 */
+	private Map< SourceGroup, List< SourceAndConverter< ? > > > getSelectedSourcesInGroups()
+	{
+		final Map< SourceGroup, List< SourceAndConverter< ? > > > selected = new HashMap<>();
+		for ( final TreePath path : getSelectionPaths() )
+		{
+			final Object obj = path.getLastPathComponent();
+			if ( obj instanceof SourceModel )
+			{
+				final SourceGroup group = ( ( GroupModel ) path.getParentPath().getLastPathComponent() ).getGroup();
+				final SourceAndConverter< ? > source = ( ( SourceModel ) obj ).getSource();
+				selected.computeIfAbsent( group, g -> new ArrayList<>() ).add( source );
+			}
+		}
+		return selected;
+	}
+
+	@Override
+	public String convertValueToText( final Object value, final boolean selected, final boolean expanded, final boolean leaf, final int row, final boolean hasFocus )
+	{
+		if ( value instanceof GroupModel )
+		{
+			final String groupName = ( ( GroupModel ) value ).getName();
+			if ( groupName != null )
+				return groupName;
+		}
+		return "";
+	}
+
+	// -- Process clicks on active and current checkboxes --
+	// These clicks are consumed, because they should not cause selection changes, etc, in the tree.
+
+	private Point pressedAt;
+	private boolean consumeNext = false;
+	private long releasedWhen = 0;
+
+	@Override
+	protected void processMouseEvent( final MouseEvent e )
+	{
+		MouseEvent modifiedMouseEvent = e;
+
+		if ( e.getModifiers() == InputEvent.BUTTON1_MASK )
+		{
+			if ( e.getID() == MouseEvent.MOUSE_PRESSED )
+			{
+				pressedAt = e.getPoint();
+				int x = e.getX();
+				int y = e.getY();
+				final TreePath path = getPathForLocation( x, y );
+
+				if ( path != null )
+				{
+					final Rectangle bounds = getPathBounds( path );
+
+					if ( e.getX() >= bounds.getX() + bounds.getWidth() )
+					{
+						modifiedMouseEvent = new MouseEvent(
+								( Component ) e.getSource(), e.getID(), e.getWhen(), e.getModifiersEx(),
+								( int ) ( bounds.getX() + bounds.getWidth() - 1 ), e.getY(),
+								0, 0, e.getClickCount(), e.isPopupTrigger(), e.getButton() );
+					}
+
+					if ( path.getLastPathComponent() instanceof GroupModel )
+					{
+						x -= bounds.getX();
+						y -= bounds.getY();
+						final boolean currentHit = renderer.currentHit( x, y );
+						final boolean activeHit = !currentHit && renderer.activeHit( x, y );
+						if ( currentHit || activeHit )
+						{
+							if ( isPathSelected( path )  )
+							{
+								e.consume();
+								consumeNext = true;
+							}
+						}
+					}
+				}
+			}
+			else if ( e.getID() == MouseEvent.MOUSE_RELEASED )
+			{
+				if ( consumeNext )
+				{
+					releasedWhen = e.getWhen();
+					consumeNext = false;
+					e.consume();
+				}
+
+				if ( pressedAt == null )
+					return;
+
+				final Point point = e.getPoint();
+				if ( point.distanceSq( pressedAt ) > 2 )
+					return;
+
+				int x = e.getX();
+				int y = e.getY();
+				final TreePath path = getPathForLocation( x, y );
+				if ( path != null && path.getLastPathComponent() instanceof GroupModel )
+				{
+					final Rectangle bounds = getPathBounds( path );
+					x -= bounds.getX();
+					y -= bounds.getY();
+					final boolean currentHit = renderer.currentHit( x, y );
+					final boolean activeHit = !currentHit && renderer.activeHit( x, y );
+					if ( currentHit || activeHit )
+					{
+						final SourceGroup group = ( ( GroupModel ) path.getLastPathComponent() ).getGroup();
+						if ( currentHit )
+							state.setCurrentGroup( group );
+						else
+						{
+							if ( isPathSelected( path ) )
+								state.setGroupsActive( getSelectedGroups(), !state.isGroupActive( group ) );
+							else
+								state.setGroupActive( group, !state.isGroupActive( group ) );
+						}
+					}
+				}
+			}
+			else if ( e.getID() == MouseEvent.MOUSE_CLICKED )
+			{
+				if ( e.getWhen() == releasedWhen )
+					e.consume();
+			}
+		}
+
+		super.processMouseEvent( modifiedMouseEvent );
+	}
+
+	@Override
+	public TreePath getPathForLocation( final int x, final int y )
+	{
+		final TreePath closestPath = getClosestPathForLocation( x, y );
+
+		if ( closestPath != null )
+		{
+			final Rectangle pathBounds = getPathBounds( closestPath );
+
+			if ( pathBounds != null &&
+					x >= pathBounds.x &&
+					y >= pathBounds.y && y < ( pathBounds.y + pathBounds.height ) )
+				return closestPath;
+		}
+		return null;
+	}
+
+	@Override
+	public boolean isPathEditable( final TreePath path )
+	{
+		return path != null && path.getLastPathComponent() instanceof GroupModel;
+	}
+
+	@Override
+	public void paintComponent( final Graphics g )
+	{
+		g.setColor( getBackground() );
+		g.fillRect( 0, 0, getWidth(), getHeight() );
+		final int[] rows = getSelectionRows();
+		if ( rows != null )
+		{
+			g.setColor( renderer.getBackgroundSelectionColor() );
+			for ( final int i : rows )
+			{
+				final Rectangle r = getRowBounds( i );
+				g.fillRect( 0, r.y, getWidth(), r.height );
+			}
+		}
+		super.paintComponent( g );
+	}
+
+	public TreePath getPathTo( final SourceGroup group )
+	{
+		synchronized ( state )
+		{
+			if ( state.containsGroup( group ) )
+				return model.getPathTo( new GroupModel( group, state ) );
+			else
+				return null;
+		}
+	}
+
+	/**
+	 * {@code TransferHandler} for transferring sources into a group of a {@code SourceGroupTree} via cut/copy/paste and drag and drop.
+	 */
+	public static class SourceGroupTreeTransferHandler extends TransferHandler
+	{
+		private final SourceGroupTree tree;
+
+		private final ViewerState state;
+
+		public SourceGroupTreeTransferHandler( final SourceGroupTree tree, final ViewerState state )
+		{
+			this.tree = tree;
+			this.state = state;
+		}
+
+		@Override
+		public boolean canImport( final TransferSupport support )
+		{
+			final boolean canDrop = support.isDataFlavorSupported( SourcesTransferable.flavor );
+			support.setShowDropLocation( canDrop );
+			return canDrop;
+		}
+
+		@Override
+		public boolean importData( final TransferSupport support )
+		{
+			if ( !canImport( support ) )
+				return false;
+
+			try
+			{
+				final Transferable t = support.getTransferable();
+				final List< SourceAndConverter< ? > > sources = ( ( SourcesTransferable.SourceList ) t.getTransferData( SourcesTransferable.flavor ) ).getSources();
+
+				final DropLocation dropLocation = support.getDropLocation();
+				if ( dropLocation instanceof JTree.DropLocation )
+				{
+					final JTree.DropLocation drop = ( JTree.DropLocation ) dropLocation;
+
+					final TreePath path = drop.getPath();
+					if ( path == null )
+					{
+						final SourceGroup group = new SourceGroup();
+						state.addGroup( group );
+						state.setGroupName( group, "new group" );
+						state.addSourcesToGroup( sources, group );
+						SwingUtilities.invokeLater( () -> {
+							final TreePath path1 = tree.getPathTo( group );
+							tree.expandPath( path1 );
+							tree.startEditingAtPath( path1 );
+						} );
+					}
+					else
+					{
+						final GroupModel groupModel = ( GroupModel ) path.getPathComponent( 1 );
+						state.addSourcesToGroup( sources, groupModel.getGroup() );
+						SwingUtilities.invokeLater( () -> {
+							tree.expandPath( tree.getPathTo( groupModel.getGroup() ) );
+						} );
+					}
+					return true;
+				}
+			}
+			catch ( final Exception e )
+			{}
+			return false;
+		}
+	}
+}
diff --git a/src/main/java/bdv/ui/sourcegrouptree/SourceGroupTreeCellRenderer.java b/src/main/java/bdv/ui/sourcegrouptree/SourceGroupTreeCellRenderer.java
new file mode 100644
index 0000000000000000000000000000000000000000..1e5327abe3965b574bacf7959fea0a29aa6493b7
--- /dev/null
+++ b/src/main/java/bdv/ui/sourcegrouptree/SourceGroupTreeCellRenderer.java
@@ -0,0 +1,430 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.ui.sourcegrouptree;
+
+import bdv.ui.sourcegrouptree.SourceGroupTreeModel.GroupModel;
+import bdv.ui.sourcegrouptree.SourceGroupTreeModel.SourceModel;
+import java.awt.Color;
+import java.awt.Component;
+import java.awt.ComponentOrientation;
+import java.awt.Font;
+import java.awt.Graphics;
+import javax.swing.BoxLayout;
+import javax.swing.JCheckBox;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JRadioButton;
+import javax.swing.JTree;
+import javax.swing.UIManager;
+import javax.swing.border.EmptyBorder;
+import javax.swing.plaf.FontUIResource;
+import javax.swing.plaf.basic.BasicGraphicsUtils;
+import javax.swing.tree.DefaultTreeCellRenderer;
+import javax.swing.tree.TreeCellRenderer;
+
+/**
+ * @author Tobias Pietzsch
+ */
+class SourceGroupTreeCellRenderer implements TreeCellRenderer
+{
+	/**
+	 * Last tree the renderer was painted in.
+	 */
+	private JTree tree;
+
+	/**
+	 * Is the value currently selected.
+	 */
+	private boolean selected;
+
+	/**
+	 * True if has focus.
+	 */
+	private boolean hasFocus;
+
+
+	// Color to use for the foreground for selected nodes.
+	private final Color textSelectionColor;
+
+	// Color to use for the foreground for non-selected nodes.
+	private final Color textNonSelectionColor;
+
+	// Color to use for the background when a node is selected.
+	private Color backgroundSelectionColor;
+
+	// Color to use for the background when the node isn't selected.
+	private final Color backgroundNonSelectionColor;
+
+	// Color to use for the focus indicator when the node has focus.
+	private final Color borderSelectionColor;
+
+	// TODO
+	private final Color backgroundDropCellColor;
+
+	// TODO
+	private final boolean fillBackground;
+
+	// If true, a dashed line is drawn as the focus indicator.
+	private final boolean drawDashedFocusIndicator;
+
+	// If drawDashedFocusIndicator is true, the following are used.
+
+	// Background color of the tree.
+	private Color treeBGColor;
+
+	// Color to draw the focus indicator in, determined from the background color.
+	private Color focusBGColor;
+
+	// actual renderers
+	private GroupRenderer groupRenderer = new GroupRenderer();
+	private SourceRenderer sourceRenderer = new SourceRenderer();
+
+	// fallback for debugging
+	private final DefaultTreeCellRenderer defaultRenderer = new DefaultTreeCellRenderer();
+
+	SourceGroupTreeCellRenderer()
+	{
+		textSelectionColor = getUIColor( "Tree.selectionForeground" );
+		textNonSelectionColor = getUIColor( "Tree.textForeground" );
+		backgroundSelectionColor = getUIColor( "Tree.selectionBackground" );
+		backgroundNonSelectionColor = getUIColor( "Tree.textBackground" );
+		borderSelectionColor = getUIColor( "Tree.selectionBorderColor" );
+		backgroundDropCellColor = getUIColor( "Tree.dropCellBackground", backgroundSelectionColor );
+		fillBackground = getUIBoolean( "Tree.rendererFillBackground", true );
+		drawDashedFocusIndicator = getUIBoolean( "Tree.drawDashedFocusIndicator", false );
+	}
+
+	/**
+	 * Sets the color to use for the background if node is selected.
+	 */
+	public void setBackgroundSelectionColor( final Color newColor )
+	{
+		backgroundSelectionColor = newColor;
+	}
+
+	/**
+	 * Returns the color to use for the background if node is selected.
+	 */
+	public Color getBackgroundSelectionColor()
+	{
+		return backgroundSelectionColor;
+	}
+
+	private static boolean getUIBoolean( String key, boolean defaultValue )
+	{
+		final Object value = UIManager.get( key );
+		if ( value instanceof Boolean )
+			return ( Boolean ) value;
+		else
+			return defaultValue;
+	}
+
+	private static Color getUIColor( String key )
+	{
+		return getUIColor( key, null );
+	}
+
+	private static Color getUIColor( String key, Color defaultValue )
+	{
+		final Object value = UIManager.get( key );
+		if ( value instanceof Color )
+			return ( Color ) value;
+		else
+			return defaultValue;
+	}
+
+	@Override
+	public Component getTreeCellRendererComponent( final JTree tree, final Object value, final boolean selected, final boolean expanded, final boolean leaf, final int row, final boolean hasFocus )
+	{
+		this.tree = tree;
+		this.hasFocus = hasFocus;
+		this.selected = selected;
+
+		if ( value instanceof GroupModel )
+			return groupRenderer.getTreeCellRendererComponent( ( GroupModel ) value );
+		else if ( value instanceof SourceModel )
+			return sourceRenderer.getTreeCellRendererComponent( ( SourceModel ) value );
+		else
+			return defaultRenderer.getTreeCellRendererComponent( tree, value, selected, expanded, leaf, row, hasFocus );
+	}
+
+	public int determineOffset( final Object value )
+	{
+		if ( value instanceof GroupModel )
+			return groupRenderer.getOffset();
+		else if ( value instanceof SourceModel )
+			return sourceRenderer.getOffset();
+		else
+			return 0;
+	}
+
+	public boolean currentHit( final int x, final int y )
+	{
+		return groupRenderer.currentHit( x, y );
+	}
+
+	public boolean activeHit( final int x, final int y )
+	{
+		return groupRenderer.activeHit( x, y );
+	}
+
+	class TreeLabel extends JLabel
+	{
+		@Override
+		public void setFont( Font font )
+		{
+			if ( font instanceof FontUIResource )
+				font = null;
+			super.setFont( font );
+		}
+
+		@Override
+		public Font getFont()
+		{
+			Font font = super.getFont();
+			if ( font == null && tree != null )
+			{
+				font = tree.getFont();
+			}
+			return font;
+		}
+	}
+
+	private void paintFocus( Graphics g, int x, int y, int w, int h, Color notColor )
+	{
+		Color bsColor = borderSelectionColor;
+
+		if ( bsColor != null && ( selected || !drawDashedFocusIndicator ) )
+		{
+			g.setColor( bsColor );
+			g.drawRect( x, y, w - 1, h - 1 );
+		}
+		if ( drawDashedFocusIndicator && notColor != null )
+		{
+			if ( treeBGColor != notColor )
+			{
+				treeBGColor = notColor;
+				focusBGColor = new Color( ~notColor.getRGB() );
+			}
+			g.setColor( focusBGColor );
+			BasicGraphicsUtils.drawDashedRect( g, x, y, w, h );
+		}
+	}
+
+	class GroupRenderer extends JPanel
+	{
+		private final TreeLabel nameLabel;
+
+		private final JRadioButton currentRadioButton;
+
+		private final JCheckBox activeCheckBox;
+
+		GroupRenderer()
+		{
+			setLayout( new BoxLayout( this, BoxLayout.X_AXIS ) );
+			setBorder( new EmptyBorder( 0, 0, 0, 0 ) );
+			nameLabel = new TreeLabel();
+//			UIUtils.setPreferredWidth( nameLabel, 100 );
+			currentRadioButton = new JRadioButton();
+			currentRadioButton.setBorder( new EmptyBorder( 0, 0, 0, 5 ) );
+			currentRadioButton.setOpaque( false );
+			activeCheckBox = new JCheckBox();
+			activeCheckBox.setBorder( new EmptyBorder( 0, 0, 0, 5 ) );
+			activeCheckBox.setOpaque( false );
+			add( currentRadioButton );
+			add( activeCheckBox );
+			add( nameLabel );
+			setOpaque( false );
+			invalidate();
+		}
+
+		@Override
+		public void setFont( Font font )
+		{
+			if ( font instanceof FontUIResource )
+				font = null;
+			super.setFont( font );
+		}
+
+		@Override
+		public Font getFont()
+		{
+			Font font = super.getFont();
+			if ( font == null && tree != null )
+			{
+				font = tree.getFont();
+			}
+			return font;
+		}
+
+		public Component getTreeCellRendererComponent( final GroupModel group )
+		{
+			nameLabel.setText( group.getName() );
+			currentRadioButton.setSelected( group.isCurrent() );
+			activeCheckBox.setSelected( group.isActive() );
+
+			final Color fg;
+			if ( selected )
+				fg = textSelectionColor;
+			else
+				fg = textNonSelectionColor;
+			nameLabel.setForeground( fg );
+
+			final boolean enabled = tree.isEnabled();
+			setEnabled( enabled );
+			nameLabel.setEnabled( enabled );
+			currentRadioButton.setEnabled( enabled );
+			activeCheckBox.setEnabled( enabled );
+
+			final ComponentOrientation componentOrientation = tree.getComponentOrientation();
+			setComponentOrientation( componentOrientation );
+			nameLabel.setComponentOrientation( componentOrientation );
+			currentRadioButton.setComponentOrientation( componentOrientation );
+			activeCheckBox.setComponentOrientation( componentOrientation );
+
+			invalidate();
+			return this;
+		}
+
+		// TODO
+		private boolean isDropCell = false;
+
+		/**
+		 * Paints the value.  The background is filled based on selected.
+		 */
+		@Override
+		public void paint( Graphics g )
+		{
+			Color bColor;
+
+			if ( isDropCell )
+				bColor = backgroundDropCellColor;
+			else if ( selected )
+				bColor = backgroundSelectionColor;
+			else
+				bColor = backgroundNonSelectionColor;
+
+			if ( bColor == null )
+				bColor = getBackground();
+
+			if ( bColor != null && fillBackground )
+			{
+				g.setColor( bColor );
+				g.fillRect( 0, 0, getWidth(), getHeight() );
+			}
+
+			if ( hasFocus )
+				paintFocus( g, 0, 0, getWidth(), getHeight(), bColor );
+
+			super.paint( g );
+		}
+
+		public int getOffset()
+		{
+			return nameLabel.getX();
+		}
+
+		public boolean currentHit( final int x, final int y )
+		{
+			return currentRadioButton.getBounds().contains( x, y );
+		}
+
+		public boolean activeHit( final int x, final int y )
+		{
+			return activeCheckBox.getBounds().contains( x, y );
+		}
+	}
+
+	class SourceRenderer extends TreeLabel
+	{
+		SourceRenderer()
+		{
+			setBorder( new EmptyBorder( 0, 35, 0, 0 ) );
+			setOpaque( false );
+		}
+
+		public Component getTreeCellRendererComponent( final SourceModel source )
+		{
+			setText( source.getName() );
+
+			final Color fg;
+			if ( selected )
+				fg = textSelectionColor;
+			else
+				fg = textNonSelectionColor;
+			setForeground( fg );
+
+			final boolean enabled = tree.isEnabled();
+			setEnabled( enabled );
+
+			final ComponentOrientation componentOrientation = tree.getComponentOrientation();
+			setComponentOrientation( componentOrientation );
+
+			return this;
+		}
+
+		// TODO
+		private boolean isDropCell = false;
+
+		/**
+		 * Paints the value.  The background is filled based on selected.
+		 */
+		@Override
+		public void paint( Graphics g )
+		{
+			Color bColor;
+
+			if ( isDropCell )
+				bColor = backgroundDropCellColor;
+			else if ( selected )
+				bColor = backgroundSelectionColor;
+			else
+				bColor = backgroundNonSelectionColor;
+
+			if ( bColor == null )
+				bColor = getBackground();
+
+			if ( bColor != null && fillBackground )
+			{
+				g.setColor( bColor );
+				g.fillRect( 0, 0, getWidth(), getHeight() );
+			}
+
+			if ( hasFocus )
+				paintFocus( g, 0, 0, getWidth(), getHeight(), bColor );
+
+			super.paint( g );
+		}
+
+		public int getOffset()
+		{
+			return getX();
+		}
+	}
+}
diff --git a/src/main/java/bdv/ui/sourcegrouptree/SourceGroupTreeModel.java b/src/main/java/bdv/ui/sourcegrouptree/SourceGroupTreeModel.java
new file mode 100644
index 0000000000000000000000000000000000000000..a5b524ab7b8921023966b3093e2548f5712481c4
--- /dev/null
+++ b/src/main/java/bdv/ui/sourcegrouptree/SourceGroupTreeModel.java
@@ -0,0 +1,532 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.ui.sourcegrouptree;
+
+import bdv.util.WrappedList;
+import bdv.viewer.SourceAndConverter;
+import bdv.viewer.SourceGroup;
+import bdv.viewer.ViewerState;
+import gnu.trove.map.TObjectIntMap;
+import gnu.trove.map.hash.TObjectIntHashMap;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import javax.swing.SwingUtilities;
+import javax.swing.event.EventListenerList;
+import javax.swing.event.TreeModelEvent;
+import javax.swing.event.TreeModelListener;
+import javax.swing.tree.TreeModel;
+import javax.swing.tree.TreePath;
+
+import static gnu.trove.impl.Constants.DEFAULT_CAPACITY;
+import static gnu.trove.impl.Constants.DEFAULT_LOAD_FACTOR;
+
+/**
+ * @author Tobias Pietzsch
+ */
+public class SourceGroupTreeModel implements TreeModel
+{
+	private final EventListenerList listenerList = new EventListenerList();
+
+	private final String root = "root";
+
+	private final Object[] rootPath = new Object[] { root };
+
+	private final ViewerState state;
+
+	private StateModel model;
+
+	public SourceGroupTreeModel( final ViewerState state )
+	{
+		model = new StateModel( state );
+		this.state = state;
+		state.changeListeners().add( e ->
+		{
+			switch ( e )
+			{
+			case CURRENT_GROUP_CHANGED:
+			case GROUP_ACTIVITY_CHANGED:
+			case SOURCE_TO_GROUP_ASSIGNMENT_CHANGED:
+			case GROUP_NAME_CHANGED:
+			case NUM_GROUPS_CHANGED:
+				final StateModel model = new StateModel( state );
+				SwingUtilities.invokeLater( () -> analyzeChanges( model ) );
+			}
+		} );
+	}
+
+	@Override
+	public Object getRoot()
+	{
+		return root;
+	}
+
+	@Override
+	public Object getChild( final Object parent, final int index )
+	{
+		if ( parent == root )
+		{
+			return model.getGroups().get( index );
+		}
+		else if ( parent instanceof GroupModel )
+		{
+			return ( ( GroupModel ) parent ).getSources().get( index );
+		}
+		else
+		{
+			throw new IllegalArgumentException();
+		}
+	}
+
+	@Override
+	public int getChildCount( final Object parent )
+	{
+		if ( parent == root )
+		{
+			return model.getGroups().size();
+		}
+		else if ( parent instanceof GroupModel )
+		{
+			return ( ( GroupModel ) parent ).getSources().size();
+		}
+		else
+		{
+			return 0;
+		}
+	}
+
+	@Override
+	public boolean isLeaf( final Object node )
+	{
+		if ( node == root )
+		{
+			return model.getGroups().isEmpty();
+		}
+		else if ( node instanceof GroupModel )
+		{
+			return ( ( GroupModel ) node ).getSources().isEmpty();
+		}
+		else
+		{
+			return true;
+		}
+	}
+
+	@Override
+	public void valueForPathChanged( final TreePath path, final Object newValue )
+	{
+		final Object o = path.getLastPathComponent();
+		if ( o instanceof GroupModel )
+			state.setGroupName( ( ( GroupModel ) o ).group, newValue.toString() );
+	}
+
+	@Override
+	public int getIndexOfChild( final Object parent, final Object child )
+	{
+		if ( parent == root )
+		{
+			return model.getGroups().indexOf( child );
+		}
+		else if ( parent instanceof GroupModel )
+		{
+			return ( ( GroupModel ) parent ).getSources().indexOf( child );
+		}
+		else
+		{
+			throw new IllegalArgumentException();
+		}
+	}
+
+	//
+	//  Events
+	//
+
+	/**
+	 * Adds a listener for the TreeModelEvent posted after the tree changes.
+	 *
+	 * @param l
+	 * 		the listener to add
+	 *
+	 * @see #removeTreeModelListener
+	 */
+	public void addTreeModelListener( TreeModelListener l )
+	{
+		listenerList.add( TreeModelListener.class, l );
+	}
+
+	/**
+	 * Removes a listener previously added with <B>addTreeModelListener()</B>.
+	 *
+	 * @param l
+	 * 		the listener to remove
+	 *
+	 * @see #addTreeModelListener
+	 */
+	public void removeTreeModelListener( TreeModelListener l )
+	{
+		listenerList.remove( TreeModelListener.class, l );
+	}
+
+	private void fireTreeNodesChanged(final TreeModelEvent e)
+	{
+		final Object[] listeners = listenerList.getListenerList();
+		for ( int i = listeners.length - 2; i >= 0; i -= 2 )
+			if ( listeners[ i ] == TreeModelListener.class )
+				( ( TreeModelListener ) listeners[ i + 1 ] ).treeNodesChanged( e );
+	}
+
+	private void fireTreeNodesInserted(final TreeModelEvent e)
+	{
+		final Object[] listeners = listenerList.getListenerList();
+		for ( int i = listeners.length - 2; i >= 0; i -= 2 )
+			if ( listeners[ i ] == TreeModelListener.class )
+				( ( TreeModelListener ) listeners[ i + 1 ] ).treeNodesInserted( e );
+	}
+
+	private void fireTreeNodesRemoved(final TreeModelEvent e)
+	{
+		final Object[] listeners = listenerList.getListenerList();
+		for ( int i = listeners.length - 2; i >= 0; i -= 2 )
+			if ( listeners[ i ] == TreeModelListener.class )
+				( ( TreeModelListener ) listeners[ i + 1 ] ).treeNodesRemoved( e );
+	}
+
+	private void fireTreeStructureChanged(final TreeModelEvent e)
+	{
+		final Object[] listeners = listenerList.getListenerList();
+		for ( int i = listeners.length - 2; i >= 0; i -= 2 )
+			if ( listeners[ i ] == TreeModelListener.class )
+				( ( TreeModelListener ) listeners[ i + 1 ] ).treeStructureChanged( e );
+	}
+
+	public TreePath getPathTo( final GroupModel group )
+	{
+		return new TreePath( new Object[] { root, group } );
+	}
+
+	private void analyzeChanges( final StateModel model )
+	{
+		final StateModel previousModel = this.model;
+		this.model = model;
+
+		// -- NUM_GROUPS_CHANGED --
+
+		final List< GroupModel > removedGroups = new ArrayList<>();
+		for ( GroupModel group : previousModel.getGroups() )
+			if ( !model.getGroups().contains( group ) )
+				removedGroups.add( group );
+
+		final List< GroupModel > addedGroups = new ArrayList<>();
+		for ( GroupModel group : model.getGroups() )
+			if ( !previousModel.getGroups().contains( group ) )
+				addedGroups.add( group );
+
+		// -- GROUP_NAME_CHANGED, CURRENT_GROUP_CHANGED, GROUP_ACTIVITY_CHANGED --
+
+		final List< GroupModel > changedGroups = new ArrayList<>();
+		for ( GroupModel group : model.getGroups() )
+		{
+			final GroupModel previousGroup = previousModel.getGroups().get( group );
+			if ( previousGroup != null )
+			{
+				if ( group.isCurrent() != previousGroup.isCurrent() ||
+						group.isActive() != previousGroup.isActive() ||
+						!Objects.equals( group.getName(), previousGroup.getName() ) )
+				{
+					changedGroups.add( group );
+				}
+			}
+		}
+
+		// -- SOURCE_TO_GROUP_ASSIGNMENT_CHANGED --
+
+		final List< GroupModel > structurallyChangedGroups = new ArrayList<>();
+		for ( GroupModel group : model.getGroups() )
+		{
+			final GroupModel previousGroup = previousModel.getGroups().get( group );
+			if ( previousGroup != null )
+			{
+				final List< SourceModel > content = group.getSources();
+				final List< SourceModel > previousContent = previousGroup.getSources();
+				if ( !content.equals( previousContent ) )
+					structurallyChangedGroups.add( group );
+			}
+		}
+
+		// -- create corresponding TreeModelEvents --
+
+		// groups added or removed
+		if ( !addedGroups.isEmpty() )
+		{
+			final int[] childIndices = new int[ addedGroups.size() ];
+			Arrays.setAll( childIndices, i -> model.getGroups().indexOf( addedGroups.get( i ) ) );
+			final Object[] children = addedGroups.toArray( new Object[ 0 ] );
+			fireTreeNodesInserted( new TreeModelEvent( this, rootPath, childIndices, children ) );
+		}
+		else if ( !removedGroups.isEmpty() )
+		{
+			final int[] childIndices = new int[ removedGroups.size() ];
+			Arrays.setAll( childIndices, i -> previousModel.getGroups().indexOf( removedGroups.get( i ) ) );
+			final Object[] children = removedGroups.toArray( new Object[ 0 ] );
+			fireTreeNodesRemoved( new TreeModelEvent( this, rootPath, childIndices, children ) );
+		}
+
+		// groups that change currentness, activeness, or name
+		if ( !changedGroups.isEmpty() )
+		{
+			final int[] childIndices = new int[ changedGroups.size() ];
+			Arrays.setAll( childIndices, i -> model.getGroups().indexOf( changedGroups.get( i ) ) );
+			final Object[] children = changedGroups.toArray( new Object[ 0 ] );
+			fireTreeNodesChanged( new TreeModelEvent( this, rootPath, childIndices, children ) );
+		}
+
+		// groups that had children added or removed
+		if ( !structurallyChangedGroups.isEmpty() )
+		{
+			for ( GroupModel group : structurallyChangedGroups )
+			{
+				final Object[] path = new Object[] { root, group };
+				fireTreeStructureChanged( new TreeModelEvent( this, path, null, null ) );
+			}
+		}
+	}
+
+	//
+	//  Internal state model
+	//
+
+	private static final int NO_ENTRY_VALUE = -1;
+
+	static class StateModel
+	{
+		private final UnmodifiableGroups groups;
+
+		public StateModel( final ViewerState state )
+		{
+			final List< GroupModel > glist = new ArrayList<>();
+			final TObjectIntMap< GroupModel > gindices = new TObjectIntHashMap<>( DEFAULT_CAPACITY, DEFAULT_LOAD_FACTOR, NO_ENTRY_VALUE );
+			final List< SourceGroup > sgroups = state.getGroups();
+			for ( int i = 0; i < sgroups.size(); ++i )
+			{
+				final GroupModel groupModel = new GroupModel( sgroups.get( i ), state );
+				glist.add( groupModel );
+				gindices.put( groupModel, i );
+			}
+			groups = new UnmodifiableGroups( glist, gindices );
+		}
+
+		public UnmodifiableGroups getGroups()
+		{
+			return groups;
+		}
+
+		static class UnmodifiableGroups extends WrappedList< GroupModel >
+		{
+			private final TObjectIntMap< GroupModel > groupIndices;
+
+			public UnmodifiableGroups(final List< GroupModel > groups, final TObjectIntMap< GroupModel > groupIndices)
+			{
+				super( Collections.unmodifiableList( groups ) );
+				this.groupIndices = groupIndices;
+			}
+
+			public GroupModel get( GroupModel groupModel )
+			{
+				final int index = groupIndices.get( groupModel );
+				return index == NO_ENTRY_VALUE ? null : get( index );
+			}
+
+			@Override
+			public boolean contains( final Object o )
+			{
+				return groupIndices.containsKey( o );
+			}
+
+			@Override
+			public boolean containsAll( final Collection< ? > c )
+			{
+				return groupIndices.keySet().containsAll( c );
+			}
+
+			@Override
+			public int indexOf( final Object o )
+			{
+				return groupIndices.get( o );
+			}
+
+			@Override
+			public int lastIndexOf( final Object o )
+			{
+				return groupIndices.get( o );
+			}
+		}
+	}
+
+	static class GroupModel
+	{
+		private final String name;
+		private final boolean active;
+		private final boolean current;
+
+		private final List< SourceModel > sources;
+
+		private final SourceGroup group;
+
+		public GroupModel( final SourceGroup group, final ViewerState state )
+		{
+			name = state.getGroupName( group );
+			active = state.isGroupActive( group );
+			current = state.isCurrentGroup( group );
+
+			final List< SourceAndConverter< ? > > orderedSources = new ArrayList<>( state.getSourcesInGroup( group ) );
+			orderedSources.sort( state.sourceOrder() );
+			final List< SourceModel > slist = new ArrayList<>();
+			final TObjectIntMap< SourceModel > sindices = new TObjectIntHashMap<>( DEFAULT_CAPACITY, DEFAULT_LOAD_FACTOR, NO_ENTRY_VALUE );
+			for ( int i = 0; i < orderedSources.size(); ++i )
+			{
+				final SourceModel sourceModel = new SourceModel( orderedSources.get( i ) );
+				slist.add( sourceModel );
+				sindices.put( sourceModel, i );
+			}
+			sources = new UnmodifiableSources( slist, sindices );
+
+			this.group = group;
+		}
+
+		public String getName()
+		{
+			return name;
+		}
+
+		public boolean isActive()
+		{
+			return active;
+		}
+
+		public boolean isCurrent()
+		{
+			return current;
+		}
+
+		public List< SourceModel > getSources()
+		{
+			return sources;
+		}
+
+		public SourceGroup getGroup()
+		{
+			return group;
+		}
+
+		@Override
+		public boolean equals( final Object o )
+		{
+			return ( o instanceof GroupModel ) && group.equals( ( ( GroupModel ) o ).group );
+		}
+
+		@Override
+		public int hashCode()
+		{
+			return group.hashCode();
+		}
+
+		static class UnmodifiableSources extends WrappedList< SourceModel >
+		{
+			private final TObjectIntMap< SourceModel > sourceIndices;
+
+			public UnmodifiableSources( final List< SourceModel > sources, final TObjectIntMap< SourceModel > sourceIndices )
+			{
+				super( Collections.unmodifiableList( sources ) );
+				this.sourceIndices = sourceIndices;
+			}
+
+			@Override
+			public boolean contains( final Object o )
+			{
+				return sourceIndices.containsKey( o );
+			}
+
+			@Override
+			public boolean containsAll( final Collection< ? > c )
+			{
+				return sourceIndices.keySet().containsAll( c );
+			}
+
+			@Override
+			public int indexOf( final Object o )
+			{
+				return sourceIndices.get( o );
+			}
+
+			@Override
+			public int lastIndexOf( final Object o )
+			{
+				return sourceIndices.get( o );
+			}
+		}
+	}
+
+	static class SourceModel
+	{
+		private final String name;
+
+		private final SourceAndConverter< ? > source;
+
+		public SourceModel( final SourceAndConverter< ? > source )
+		{
+			name = source.getSpimSource().getName();
+			this.source = source;
+		}
+
+		public String getName()
+		{
+			return name;
+		}
+
+		public SourceAndConverter<?> getSource()
+		{
+			return source;
+		}
+
+		@Override
+		public boolean equals( final Object o )
+		{
+			return ( o instanceof SourceModel ) && source.equals( ( ( SourceModel ) o ).source );
+		}
+
+		@Override
+		public int hashCode()
+		{
+			return source.hashCode();
+		}
+	}
+}
diff --git a/src/main/java/bdv/ui/sourcetable/ColorRenderer.java b/src/main/java/bdv/ui/sourcetable/ColorRenderer.java
new file mode 100644
index 0000000000000000000000000000000000000000..30d29c8e923a4b9880f0925d39e2a8c6b6d83e0e
--- /dev/null
+++ b/src/main/java/bdv/ui/sourcetable/ColorRenderer.java
@@ -0,0 +1,137 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.ui.sourcetable;
+
+import java.awt.BasicStroke;
+import java.awt.Color;
+import java.awt.Component;
+import java.awt.Graphics;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import javax.swing.Icon;
+import javax.swing.JLabel;
+import javax.swing.JTable;
+import javax.swing.SwingConstants;
+import javax.swing.border.EmptyBorder;
+import javax.swing.table.TableCellRenderer;
+import net.imglib2.type.numeric.ARGBType;
+
+/**
+ * @author Tobias Pietzsch
+ */
+class ColorRenderer extends JLabel implements TableCellRenderer
+{
+	private final NoColorIcon noColorIcon;
+
+	public ColorRenderer()
+	{
+		setBorder( new EmptyBorder( 0, 0, 0, 0 ) );
+		setOpaque( true );
+		setHorizontalAlignment( SwingConstants.CENTER );
+		noColorIcon = new NoColorIcon( 14, 14 );
+	}
+
+	@Override
+	public Component getTableCellRendererComponent( final JTable table, final Object value, final boolean isSelected, final boolean hasFocus, final int row, final int column )
+	{
+		if ( value == null )
+		{
+			setBackground( isSelected ? table.getSelectionBackground() : table.getBackground() );
+			setIcon( noColorIcon );
+		}
+		else
+		{
+			setBackground( new Color( ( ( ARGBType ) value ).get() ) );
+			setIcon( null );
+		}
+		return this;
+	}
+
+	private static class NoColorIcon implements Icon
+	{
+		private final int width;
+		private final int height;
+
+		private final int size; // == min(width, height)
+		private final int ox;
+		private final int oy;
+		private final int lox0;
+		private final int loy0;
+		private final int lox1;
+		private final int loy1;
+
+		private final Color color = new Color( 0xBBBBBB );
+		private final BasicStroke stroke = new BasicStroke( 2 );
+
+		public NoColorIcon( final int width, final int height )
+		{
+			this.width = width;
+			this.height = height;
+
+			size = Math.min( width, height );
+			ox = ( width - size ) / 2;
+			oy = ( height - size ) / 2;
+			lox0 = ( int ) ( ox + size * ( 0.5 * ( 1 - Math.sqrt( 0.5 ) ) ) );
+			loy0 = ( int ) ( oy + size * ( 0.5 * ( 1 - Math.sqrt( 0.5 ) ) ) );
+			lox1 = ( int ) ( ox + size * ( 0.5 * ( 1 + Math.sqrt( 0.5 ) ) ) );
+			loy1 = ( int ) ( oy + size * ( 0.5 * ( 1 + Math.sqrt( 0.5 ) ) ) );
+		}
+
+		@Override
+		public void paintIcon( final Component c, final Graphics g, final int x, final int y )
+		{
+			final Graphics2D g2d = ( Graphics2D ) g;
+			g2d.setRenderingHint( RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON );
+			g2d.setColor( color );
+			g2d.setStroke( stroke );
+
+			final int lx0 = x + lox0;
+			final int ly0 = y + loy0;
+			final int lx1 = x + lox1;
+			final int ly1 = y + loy1;
+			g2d.drawLine( lx0, ly0, lx1, ly1 );
+
+			final int x0 = x + ox;
+			final int y0 = y + oy;
+			g2d.drawOval( x0, y0, size, size );
+		}
+
+		@Override
+		public int getIconWidth()
+		{
+			return width;
+		}
+
+		@Override
+		public int getIconHeight()
+		{
+			return height;
+		}
+	}
+}
diff --git a/src/main/java/bdv/ui/sourcetable/RadioButtonRenderer.java b/src/main/java/bdv/ui/sourcetable/RadioButtonRenderer.java
new file mode 100644
index 0000000000000000000000000000000000000000..bb247a839de2757cd8ccdffa61dfbd84ba58e148
--- /dev/null
+++ b/src/main/java/bdv/ui/sourcetable/RadioButtonRenderer.java
@@ -0,0 +1,78 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.ui.sourcetable;
+
+import java.awt.Component;
+import javax.swing.JLabel;
+import javax.swing.JRadioButton;
+import javax.swing.JTable;
+import javax.swing.UIManager;
+import javax.swing.border.Border;
+import javax.swing.border.EmptyBorder;
+import javax.swing.table.TableCellRenderer;
+
+/**
+ * @author Tobias Pietzsch
+ */
+class RadioButtonRenderer extends JRadioButton implements TableCellRenderer
+{
+	private static final Border noFocusBorder = new EmptyBorder(1, 1, 1, 1);
+
+	public RadioButtonRenderer() {
+		setHorizontalAlignment( JLabel.CENTER);
+		setBorderPainted( true );
+	}
+
+	@Override
+	public Component getTableCellRendererComponent( final JTable table, final Object value, final boolean isSelected, final boolean hasFocus, final int row, final int column )
+	{
+		if ( isSelected )
+		{
+			setForeground( table.getSelectionForeground() );
+			setBackground( table.getSelectionBackground() );
+		}
+		else
+		{
+			setForeground( table.getForeground() );
+			setBackground( table.getBackground() );
+		}
+		setSelected( ( value != null && ( Boolean ) value ) );
+
+		if ( hasFocus )
+		{
+			setBorder( UIManager.getBorder( "Table.focusCellHighlightBorder" ) );
+		}
+		else
+		{
+			setBorder( noFocusBorder );
+		}
+
+		return this;
+	}
+}
diff --git a/src/main/java/bdv/ui/sourcetable/SourceTable.java b/src/main/java/bdv/ui/sourcetable/SourceTable.java
new file mode 100644
index 0000000000000000000000000000000000000000..d29a4cbd713f838607b078cec7637279ab348d35
--- /dev/null
+++ b/src/main/java/bdv/ui/sourcetable/SourceTable.java
@@ -0,0 +1,324 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.ui.sourcetable;
+
+import bdv.tools.brightness.ConverterSetup;
+import bdv.ui.SourcesTransferable;
+import bdv.ui.UIUtils;
+import bdv.viewer.ConverterSetups;
+import bdv.viewer.SourceAndConverter;
+import bdv.viewer.SourceToConverterSetupBimap;
+import bdv.viewer.ViewerState;
+import java.awt.Color;
+import java.awt.Point;
+import java.awt.datatransfer.Transferable;
+import java.awt.event.InputEvent;
+import java.awt.event.MouseEvent;
+import java.util.ArrayList;
+import java.util.List;
+import javax.swing.JColorChooser;
+import javax.swing.JComponent;
+import javax.swing.JTable;
+import javax.swing.TransferHandler;
+import net.imglib2.type.numeric.ARGBType;
+import org.scijava.ui.behaviour.io.InputTriggerConfig;
+import org.scijava.ui.behaviour.util.Actions;
+import org.scijava.ui.behaviour.util.InputActionBindings;
+
+import static bdv.ui.sourcetable.SourceTableModel.COLOR_COLUMN;
+import static bdv.ui.sourcetable.SourceTableModel.IS_ACTIVE_COLUMN;
+import static bdv.ui.sourcetable.SourceTableModel.IS_CURRENT_COLUMN;
+
+/**
+ * A {@code JTable} that shows the sources of a {@link ViewerState} and allows
+ * to modify activeness, currentness, as well as the color of the corresponding
+ * {@link ConverterSetup}.
+ *
+ * @author Tobias Pietzsch
+ */
+public class SourceTable extends JTable
+{
+	private final SourceTableModel model;
+
+	private final ViewerState state;
+
+	private final SourceToConverterSetupBimap converters;
+
+	private final Color focusedSelectionBg;
+	private final Color unfocusedSelectionBg;
+
+	private final Color focusedSelectionFg;
+
+	public SourceTable( final ViewerState state, final ConverterSetups converterSetups )
+	{
+		this( state, converterSetups, new InputTriggerConfig() );
+	}
+
+	public SourceTable( final ViewerState state, final ConverterSetups converterSetups, final InputTriggerConfig inputTriggerConfig )
+	{
+		this.state = state;
+		converters = converterSetups;
+		model = new SourceTableModel( state, converterSetups );
+		setModel( model );
+		setTransferHandler( new SourceTableTransferHandler() );
+
+		getColumnModel().getColumn( IS_CURRENT_COLUMN ).setCellRenderer( new RadioButtonRenderer() );
+		getColumnModel().getColumn( COLOR_COLUMN ).setCellRenderer( new ColorRenderer() );
+		setRowHeight( 18 );
+
+		setShowGrid( false );
+
+		getColumnModel().getColumn( IS_CURRENT_COLUMN ).setMinWidth( 20 );
+		getColumnModel().getColumn( IS_ACTIVE_COLUMN ).setMinWidth( 20 );
+		getColumnModel().getColumn( COLOR_COLUMN ).setMinWidth( 40 );
+
+		this.installActions( inputTriggerConfig );
+
+		focusedSelectionBg = getSelectionBackground();
+		focusedSelectionFg = getSelectionForeground();
+		unfocusedSelectionBg = UIUtils.mix( focusedSelectionBg, getBackground(), 0.8 );
+	}
+
+	public void setSelectionBackground( final boolean hasFocus )
+	{
+		setSelectionForeground( focusedSelectionFg );
+		setSelectionBackground( hasFocus ? focusedSelectionBg : unfocusedSelectionBg );
+	}
+
+	public List< SourceAndConverter< ? > > getSelectedSources()
+	{
+		final List< SourceAndConverter< ? > > sources = new ArrayList<>();
+		for ( final int row : getSelectedRows() )
+			sources.add( model.getValueAt( row ).getSource() );
+		return sources;
+	}
+
+	public List< ConverterSetup > getSelectedConverterSetups()
+	{
+		return converters.getConverterSetups( getSelectedSources() );
+	}
+
+	private void installActions( final InputTriggerConfig inputTriggerConfig )
+	{
+		final InputActionBindings keybindings = InputActionBindings.installNewBindings( this, JComponent.WHEN_FOCUSED, false );
+		final Actions actions = new Actions( inputTriggerConfig, "bdv" );
+		actions.install( keybindings, "source table" );
+		actions.runnableAction( () -> toggleSelectedActive(), "toggle active", "A" );
+		actions.runnableAction( () -> makeSelectedActive( true ), "set active", "not mapped" );
+		actions.runnableAction( () -> makeSelectedActive( false ), "set inactive", "not mapped" );
+		actions.runnableAction( () -> cycleSelectedCurrent(), "cycle current", "C" );
+	}
+
+	private void makeSelectedActive( final boolean active )
+	{
+		final List< SourceAndConverter< ? > > selectedSources = getSelectedSources();
+		state.setSourcesActive( selectedSources, active );
+	}
+
+	private void toggleSelectedActive()
+	{
+		final List< SourceAndConverter< ? > > selectedSources = getSelectedSources();
+		if ( !selectedSources.isEmpty() )
+			state.setSourcesActive( selectedSources, !state.isSourceActive( selectedSources.get( 0 ) ) );
+	}
+
+	private void cycleSelectedCurrent()
+	{
+		final List< SourceAndConverter< ? > > selectedSources = getSelectedSources();
+		if ( !selectedSources.isEmpty() )
+		{
+			final SourceAndConverter< ? > current = state.getCurrentSource();
+			final int i = ( selectedSources.indexOf( current ) + 1 ) % selectedSources.size();
+			state.setCurrentSource( selectedSources.get( i ) );
+		}
+	}
+
+	// -- Process clicks on active and current checkboxes --
+	// These clicks are consumed, because they should not cause selection changes, etc, in the table.
+
+	private Point pressedAt;
+	private boolean consumeNext = false;
+	private long releasedWhen = 0;
+
+	@Override
+	protected void processMouseEvent( final MouseEvent e )
+	{
+		if ( e.getModifiers() == InputEvent.BUTTON1_MASK )
+		{
+			if ( e.getID() == MouseEvent.MOUSE_PRESSED )
+			{
+				final Point point = e.getPoint();
+				pressedAt = point;
+				final int vcol = columnAtPoint( point );
+				final int vrow = rowAtPoint( point );
+				if ( vcol >= 0 && vrow >= 0 )
+				{
+					final int mcol = convertColumnIndexToModel( vcol );
+					switch ( mcol )
+					{
+					case IS_ACTIVE_COLUMN:
+					case IS_CURRENT_COLUMN:
+					case COLOR_COLUMN:
+						final int mrow = convertRowIndexToModel( vrow );
+						if ( isRowSelected( mrow ) )
+						{
+							e.consume();
+							consumeNext = true;
+						}
+					}
+				}
+			}
+			else if ( e.getID() == MouseEvent.MOUSE_RELEASED )
+			{
+				if ( consumeNext )
+				{
+					releasedWhen = e.getWhen();
+					consumeNext = false;
+					e.consume();
+				}
+
+				if ( pressedAt == null )
+					return;
+
+				final Point point = e.getPoint();
+				if ( point.distanceSq( pressedAt ) > 2 )
+					return;
+
+				final int vcol = columnAtPoint( point );
+				final int vrow = rowAtPoint( point );
+				if ( vcol >= 0 && vrow >= 0 )
+				{
+					final int mcol = convertColumnIndexToModel( vcol );
+					switch ( mcol )
+					{
+					case IS_ACTIVE_COLUMN:
+					case IS_CURRENT_COLUMN:
+					case COLOR_COLUMN:
+						final int mrow = convertRowIndexToModel( vrow );
+						final SourceAndConverter< ? > source = model.getValueAt( mrow ).getSource();
+						if ( mcol == IS_ACTIVE_COLUMN )
+						{
+							if ( isRowSelected( mrow ) )
+								state.setSourcesActive( getSelectedSources(), !state.isSourceActive( source ) );
+							else
+								state.setSourceActive( source, !state.isSourceActive( source ) );
+						}
+						else if ( mcol == IS_CURRENT_COLUMN )
+						{
+							state.setCurrentSource( source );
+						}
+						else // if ( mcol == COLOR_COLUMN )
+						{
+							converters.getConverterSetup( source );
+							final ConverterSetup c = converters.getConverterSetup( source );
+							if ( c != null && c.supportsColor() )
+							{
+								final Color newColor = JColorChooser.showDialog( null, "Set Source Color", new Color( c.getColor().get() ) );
+								if ( newColor != null )
+								{
+									final ARGBType color = new ARGBType( newColor.getRGB() | 0xff000000 );
+									if ( isRowSelected( mrow ) )
+									{
+										for ( final SourceAndConverter< ? > s : getSelectedSources() )
+										{
+											final ConverterSetup cs = converters.getConverterSetup( s );
+											if ( cs != null && cs.supportsColor() )
+												cs.setColor( color );
+										}
+									}
+									else
+										c.setColor( color );
+								}
+							}
+						}
+					}
+				}
+			}
+			else if ( e.getID() == MouseEvent.MOUSE_CLICKED )
+			{
+				if ( e.getWhen() == releasedWhen )
+					e.consume();
+			}
+		}
+		super.processMouseEvent( e );
+	}
+
+	@Override
+	protected void processMouseMotionEvent( final MouseEvent e )
+	{
+		if ( consumeNext && e.getModifiers() == InputEvent.BUTTON1_MASK && e.getID() == MouseEvent.MOUSE_DRAGGED )
+			e.consume();
+		super.processMouseMotionEvent( e );
+	}
+
+	@Override
+	public SourceTableModel getModel()
+	{
+		return model;
+	}
+
+	/**
+	 * {@code TransferHandler} for transferring sources out of a
+	 * {@code SourceTable} via cut/copy/paste and drag and drop.
+	 */
+	static class SourceTableTransferHandler extends TransferHandler
+	{
+		@Override
+		public int getSourceActions( final JComponent c )
+		{
+			return TransferHandler.LINK;
+		}
+
+		@Override
+		protected Transferable createTransferable( final JComponent c )
+		{
+			if ( ! ( c instanceof JTable ) )
+				return null;
+
+			final JTable table = ( JTable ) c;
+
+			if ( ! ( table.getModel() instanceof SourceTableModel ) )
+				return null;
+
+			final SourceTableModel model = ( SourceTableModel ) table.getModel();
+
+			final List< SourceAndConverter< ? > > sources = new ArrayList<>();
+			for ( final int row : table.getSelectedRows() )
+				sources.add( model.getValueAt( row ).getSource() );
+
+			return new SourcesTransferable( sources );
+		}
+
+		@Override
+		public boolean canImport( final TransferSupport support )
+		{
+			return false;
+		}
+	}
+}
diff --git a/src/main/java/bdv/ui/sourcetable/SourceTableModel.java b/src/main/java/bdv/ui/sourcetable/SourceTableModel.java
new file mode 100644
index 0000000000000000000000000000000000000000..ec52ec8879c3e8b5feddd4740aab47b8c7cd596a
--- /dev/null
+++ b/src/main/java/bdv/ui/sourcetable/SourceTableModel.java
@@ -0,0 +1,355 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.ui.sourcetable;
+
+import bdv.tools.brightness.ConverterSetup;
+import bdv.util.WrappedList;
+import bdv.viewer.ConverterSetups;
+import bdv.viewer.SourceAndConverter;
+import bdv.viewer.SourceToConverterSetupBimap;
+import bdv.viewer.ViewerState;
+import gnu.trove.map.TObjectIntMap;
+import gnu.trove.map.hash.TObjectIntHashMap;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import javax.swing.SwingUtilities;
+import javax.swing.table.AbstractTableModel;
+import net.imglib2.type.numeric.ARGBType;
+
+import static gnu.trove.impl.Constants.DEFAULT_CAPACITY;
+import static gnu.trove.impl.Constants.DEFAULT_LOAD_FACTOR;
+
+/**
+ * @author Tobias Pietzsch
+ */
+public class SourceTableModel extends AbstractTableModel
+{
+	private final ViewerState state;
+
+	private final SourceToConverterSetupBimap converters;
+
+	private StateModel model;
+
+	public static final int NAME_COLUMN = 0;
+	public static final int IS_CURRENT_COLUMN = 1;
+	public static final int IS_ACTIVE_COLUMN = 2;
+	public static final int COLOR_COLUMN = 3;
+
+	public SourceTableModel( final ViewerState state, final ConverterSetups converterSetups )
+	{
+		this.state = state;
+		this.converters = converterSetups;
+		model = new StateModel( state );
+
+		converterSetups.listeners().add( converterSetup ->
+		{
+			final SourceAndConverter< ? > source = converters.getSource( converterSetup );
+			if ( source != null )
+			{
+				final SourceModel sourceModel = new SourceModel( source, state );
+				SwingUtilities.invokeLater( () -> {
+					final int row = model.getSources().indexOf( sourceModel );
+					if ( row != -1 )
+						fireTableRowsUpdated( row, row );
+				} );
+			}
+		} );
+
+		state.changeListeners().add( e ->
+		{
+			switch ( e )
+			{
+			case CURRENT_SOURCE_CHANGED:
+			case SOURCE_ACTIVITY_CHANGED:
+			case NUM_SOURCES_CHANGED:
+				final StateModel model = new StateModel( state );
+				SwingUtilities.invokeLater( () -> analyzeChanges( model ) );
+			}
+		} );
+	}
+
+	@Override
+	public int getRowCount()
+	{
+		return model.getSources().size();
+	}
+
+	@Override
+	public int getColumnCount()
+	{
+		return 4;
+	}
+
+	@Override
+	public String getColumnName( final int column )
+	{
+		switch( column )
+		{
+		case NAME_COLUMN:
+			return "name";
+		case IS_ACTIVE_COLUMN:
+			return "active";
+		case IS_CURRENT_COLUMN:
+			return "current";
+		case COLOR_COLUMN:
+			return "color";
+		default:
+			throw new IllegalArgumentException();
+		}
+	}
+
+	@Override
+	public Object getValueAt( final int rowIndex, final int columnIndex )
+	{
+		final SourceModel source = model.getSources().get( rowIndex );
+		switch( columnIndex )
+		{
+		case NAME_COLUMN:
+			return source.getName();
+		case IS_ACTIVE_COLUMN:
+			return source.isActive();
+		case IS_CURRENT_COLUMN:
+			return source.isCurrent();
+		case COLOR_COLUMN:
+			final ConverterSetup c = converters.getConverterSetup( source.getSource() );
+			return ( c != null && c.supportsColor() ) ? c.getColor() : null;
+		default:
+			throw new IllegalArgumentException();
+		}
+	}
+
+	@Override
+	public Class< ? > getColumnClass( final int columnIndex )
+	{
+		switch( columnIndex )
+		{
+		case NAME_COLUMN:
+			return String.class;
+		case IS_ACTIVE_COLUMN:
+		case IS_CURRENT_COLUMN:
+			return Boolean.class;
+		case COLOR_COLUMN:
+			return ARGBType.class;
+		default:
+			throw new IllegalArgumentException();
+		}
+	}
+
+	@Override
+	public boolean isCellEditable( final int rowIndex, final int columnIndex )
+	{
+		return columnIndex != 0;
+	}
+
+	public SourceModel getValueAt( final int rowIndex )
+	{
+		return model.getSources().get( rowIndex );
+	}
+
+	private void analyzeChanges( final StateModel model )
+	{
+		final StateModel previousModel = this.model;
+		this.model = model;
+
+		// -- NUM_SOURCES_CHANGED --
+
+		final List< SourceModel > removedSources = new ArrayList<>();
+		for ( SourceModel source : previousModel.getSources() )
+			if ( !model.getSources().contains( source ) )
+				removedSources.add( source );
+
+		final List< SourceModel > addedSources = new ArrayList<>();
+		for ( SourceModel source : model.getSources() )
+			if ( !previousModel.getSources().contains( source ) )
+				addedSources.add( source );
+
+		// -- CURRENT_SOURCE_CHANGED, SOURCE_ACTIVITY_CHANGED --
+
+		final List< SourceModel > changedSources = new ArrayList<>();
+		for ( SourceModel source : model.getSources() )
+		{
+			final SourceModel previousSource = previousModel.getSources().get( source );
+			if ( previousSource != null )
+			{
+				if ( source.isCurrent() != previousSource.isCurrent() ||
+						source.isActive() != previousSource.isActive() )
+				{
+					changedSources.add( source );
+				}
+			}
+		}
+
+		// -- create corresponding TableModelEvents --
+
+		// sources added or removed
+		if ( !addedSources.isEmpty() )
+		{
+			final int firstRow = model.getSources().indexOf( addedSources.get( 0 ) );
+			final int lastRow = model.getSources().indexOf( addedSources.get( addedSources.size() - 1 ) );
+			fireTableRowsInserted( firstRow, lastRow );
+		}
+		else if ( !removedSources.isEmpty() )
+		{
+			final int firstRow = previousModel.getSources().indexOf( removedSources.get( 0 ) );
+			final int lastRow = previousModel.getSources().indexOf( removedSources.get( removedSources.size() - 1 ) );
+			fireTableRowsDeleted( firstRow, lastRow );
+		}
+
+		// sources that changed currentness or activeness
+		if ( !changedSources.isEmpty() )
+		{
+			final int firstRow = model.getSources().indexOf( changedSources.get( 0 ) );
+			final int lastRow = model.getSources().indexOf( changedSources.get( changedSources.size() - 1 ) );
+			fireTableRowsUpdated( firstRow, lastRow );
+		}
+	}
+
+	//
+	//  Internal state model
+	//
+
+	private static final int NO_ENTRY_VALUE = -1;
+
+	static class StateModel
+	{
+		private final StateModel.UnmodifiableSources sources;
+
+		public StateModel( final ViewerState state )
+		{
+			final List< SourceModel > slist = new ArrayList<>();
+			final TObjectIntMap< SourceModel > sindices = new TObjectIntHashMap<>( DEFAULT_CAPACITY, DEFAULT_LOAD_FACTOR, NO_ENTRY_VALUE );
+			final List< SourceAndConverter< ? > > ssources = state.getSources();
+			for ( int i = 0; i < ssources.size(); ++i )
+			{
+				final SourceModel sourceModel = new SourceModel( ssources.get( i ), state );
+				slist.add( sourceModel );
+				sindices.put( sourceModel, i );
+			}
+			sources = new StateModel.UnmodifiableSources( slist, sindices );
+		}
+
+		public StateModel.UnmodifiableSources getSources()
+		{
+			return sources;
+		}
+
+		static class UnmodifiableSources extends WrappedList< SourceModel >
+		{
+			private final TObjectIntMap< SourceModel > sourceIndices;
+
+			public UnmodifiableSources( final List< SourceModel > sources, final TObjectIntMap< SourceModel > sourceIndices )
+			{
+				super( Collections.unmodifiableList( sources ) );
+				this.sourceIndices = sourceIndices;
+			}
+
+			public SourceModel get( SourceModel sourceModel )
+			{
+				final int index = sourceIndices.get( sourceModel );
+				return index == NO_ENTRY_VALUE ? null : get( index );
+			}
+
+			@Override
+			public boolean contains( final Object o )
+			{
+				return sourceIndices.containsKey( o );
+			}
+
+			@Override
+			public boolean containsAll( final Collection< ? > c )
+			{
+				return sourceIndices.keySet().containsAll( c );
+			}
+
+			@Override
+			public int indexOf( final Object o )
+			{
+				return sourceIndices.get( o );
+			}
+
+			@Override
+			public int lastIndexOf( final Object o )
+			{
+				return sourceIndices.get( o );
+			}
+		}
+	}
+
+	static class SourceModel
+	{
+		private final String name;
+		private final boolean active;
+		private final boolean current;
+
+		private final SourceAndConverter< ? > source;
+
+		public SourceModel( final SourceAndConverter< ? > source, final ViewerState state )
+		{
+			name = source.getSpimSource().getName();
+			active = state.isSourceActive( source );
+			current = state.isCurrentSource( source );
+
+			this.source = source;
+		}
+
+		public String getName()
+		{
+			return name;
+		}
+
+		public boolean isActive()
+		{
+			return active;
+		}
+
+		public boolean isCurrent()
+		{
+			return current;
+		}
+
+		public SourceAndConverter<?> getSource()
+		{
+			return source;
+		}
+
+		@Override
+		public boolean equals( final Object o )
+		{
+			return ( o instanceof SourceModel ) && source.equals( ( ( SourceModel ) o ).source );
+		}
+
+		@Override
+		public int hashCode()
+		{
+			return source.hashCode();
+		}
+	}
+}
diff --git a/src/main/java/bdv/ui/splitpanel/SplitPaneOneTouchExpandAnimator.java b/src/main/java/bdv/ui/splitpanel/SplitPaneOneTouchExpandAnimator.java
new file mode 100644
index 0000000000000000000000000000000000000000..2438d858d5370649b6aec441016f48b7b67f0f6e
--- /dev/null
+++ b/src/main/java/bdv/ui/splitpanel/SplitPaneOneTouchExpandAnimator.java
@@ -0,0 +1,495 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.ui.splitpanel;
+
+import bdv.viewer.animate.OverlayAnimator;
+import java.awt.AlphaComposite;
+import java.awt.Color;
+import java.awt.Composite;
+import java.awt.Graphics2D;
+import java.util.function.BooleanSupplier;
+import javax.swing.ImageIcon;
+
+import static bdv.ui.splitpanel.SplitPaneOneTouchExpandAnimator.AnimationType.SHOW_COLLAPSE;
+import static bdv.ui.splitpanel.SplitPaneOneTouchExpandAnimator.AnimationType.SHOW_EXPAND;
+import static bdv.ui.splitpanel.SplitPaneOneTouchExpandAnimator.AnimationType.HIDE_COLLAPSE;
+import static bdv.ui.splitpanel.SplitPaneOneTouchExpandAnimator.AnimationType.HIDE_EXPAND;
+import static bdv.ui.splitpanel.SplitPaneOneTouchExpandAnimator.AnimationType.NONE;
+
+/**
+ * @author Tim-Oliver Buchholz
+ * @author Tobias Pietzsch
+ */
+class SplitPaneOneTouchExpandAnimator implements OverlayAnimator
+{
+	private final BooleanSupplier isCollapsed;
+
+	private final ImageIcon rightArrowIcon;
+	private final ImageIcon leftArrowIcon;
+	private final int imgw;
+	private final int imgh;
+
+	private int borderWidth;
+	private int triggerHeight;
+
+	private final double animationSpeed = 0.09;
+	private final float backgroundAlpha = 0.65f;
+
+	private float alpha = 1.0f;
+
+	private int viewPortWidth;
+	private int viewPortHeight;
+
+	/**
+	 * @param isCollapsed provides collapsed state to decide whether to display left-arrow of right-arrow icon
+	 */
+	public SplitPaneOneTouchExpandAnimator( final BooleanSupplier isCollapsed )
+	{
+		this.isCollapsed = isCollapsed;
+		rightArrowIcon = new ImageIcon( SplitPaneOneTouchExpandAnimator.class.getResource( "rightdoublearrow_tiny.png" ) );
+		leftArrowIcon = new ImageIcon( SplitPaneOneTouchExpandAnimator.class.getResource( "leftdoublearrow_tiny.png" ) );
+		imgw = leftArrowIcon.getIconWidth();
+		imgh = leftArrowIcon.getIconHeight();
+		borderWidth = imgw + 10;
+		triggerHeight = imgh + 10;
+	}
+
+	public enum AnimationType
+	{
+		SHOW_EXPAND,
+		HIDE_EXPAND,
+		SHOW_COLLAPSE,
+		HIDE_COLLAPSE,
+		NONE
+	}
+
+	public synchronized void startAnimation( final AnimationType animationType )
+	{
+		requestedAnimationType = animationType;
+	}
+
+	private AnimationType requestedAnimationType = NONE;
+
+	private long last_time;
+
+	@Override
+	public void paint( final Graphics2D g, final long time )
+	{
+		viewPortWidth = g.getClipBounds().width;
+		viewPortHeight = g.getClipBounds().height;
+
+		if ( requestedAnimationType != NONE )
+		{
+			if ( animator == null || animator.animationType() != requestedAnimationType )
+			{
+				last_time = time;
+
+				switch ( requestedAnimationType )
+				{
+				case SHOW_EXPAND:
+					animator = new ShowExpandButton( animator );
+					break;
+				case HIDE_EXPAND:
+					animator = new HideExpandButton( animator );
+					break;
+				case SHOW_COLLAPSE:
+					animator = new ShowCollapseButton();
+					break;
+				case HIDE_COLLAPSE:
+					animator = new HideCollapseButton();
+					break;
+				}
+			}
+			requestedAnimationType = NONE;
+		}
+
+		if ( animator != null )
+		{
+			final long delta_time = time - last_time;
+			last_time = time;
+
+			paintState = animator.animate( delta_time );
+			if ( animator.isComplete() )
+				animator = null;
+		}
+
+		if ( paintState != null )
+			paint( g, paintState );
+	}
+
+	void clearPaintState()
+	{
+		paintState = null;
+	}
+
+	@Override
+	public boolean isComplete()
+	{
+		return false;
+	}
+
+	@Override
+	public boolean requiresRepaint()
+	{
+		return animator != null;
+	}
+
+
+	// == TRIGGER REGION =====================================================
+
+	public boolean isInBorderRegion( final int x, final int y )
+	{
+		return x > viewPortWidth - borderWidth;
+	}
+
+	public boolean isInTriggerRegion( final int x, final int y )
+	{
+		return x > viewPortWidth - borderWidth && Math.abs( viewPortHeight - 2 * y ) < triggerHeight;
+	}
+
+
+	// == PAINTING ===========================================================
+
+	private static class PaintState
+	{
+		final double imgRatio;
+		final double bgRatio;
+		final double bumpRatio;
+		final float alpha;
+
+		private PaintState( final double imgRatio, final double bgRatio, final double bumpRatio, final float alpha )
+		{
+			this.imgRatio = imgRatio;
+			this.bgRatio = bgRatio;
+			this.bumpRatio = bumpRatio;
+			this.alpha = alpha;
+		}
+	}
+
+	private PaintState paintState;
+
+	private void paint( final Graphics2D g, final PaintState state )
+	{
+		final int imgX = viewPortWidth - ( int ) ( imgw * state.imgRatio + 10 * state.bumpRatio );
+		final int bgX = viewPortWidth - ( int ) ( imgw * state.bgRatio + 10 * state.bumpRatio );
+		final int y = ( viewPortHeight - imgh ) / 2;
+
+		drawBackground( g, bgX, y, state.alpha );
+		drawImg( g, isCollapsed.getAsBoolean() ? leftArrowIcon : rightArrowIcon, imgX, y, state.alpha );
+	}
+
+	private void drawBackground( final Graphics2D g, final int x, final int y, final float alpha )
+	{
+		final int width = imgw + 60;
+		final int height = imgh;
+
+		g.setColor( new Color( 0.28f, 0.5f, 0.96f, Math.min( alpha, backgroundAlpha ) ) );
+		g.fillRoundRect( x, y, width, height, 25, 25 );
+	}
+
+	private void drawImg( final Graphics2D g, final ImageIcon img, final int x, final int y, final float alpha )
+	{
+		Composite oldComposite = g.getComposite();
+		final AlphaComposite alcom = AlphaComposite.getInstance( AlphaComposite.SRC_OVER, alpha );
+		g.setComposite( alcom );
+		g.drawImage( img.getImage(), x, y, null );
+		g.setComposite(oldComposite);
+	}
+
+
+	// == ANIMATOR HELPERS ===================================================
+
+	/**
+	 * Cosine shape acceleration/ deceleration curve  of linear [0,1]
+	 */
+	private static double cos( final double t )
+	{
+		return 0.5 - 0.5 * Math.cos( Math.PI * t );
+	}
+
+	// TODO: Animator implementations should maintain their own private alpha instead of using this shared method
+	private void updateAlpha( final boolean fadeIn, final long delta_time )
+	{
+		if ( fadeIn )
+		{
+			alpha += ( 1 / 250.0 ) * delta_time;
+			alpha = Math.min( 1, alpha );
+		}
+		else
+		{
+			alpha -= ( 1 / 250.0 ) * delta_time;
+			alpha = Math.max( 0.25f, alpha );
+		}
+	}
+
+
+	// == ANIMATORS ==========================================================
+
+	private interface Animator
+	{
+		PaintState animate( final long delta_time );
+
+		AnimationType animationType();
+
+		boolean isComplete();
+	}
+
+	private Animator animator;
+
+	// =======================================================================
+
+	private class HideCollapseButton implements Animator
+	{
+		private boolean complete = false;
+
+		@Override
+		public PaintState animate( final long delta_time )
+		{
+			// Fade-out
+			updateAlpha( false, delta_time );
+
+			complete = alpha <= 0.25;
+
+			return new PaintState( 1, 1, 0, alpha );
+		}
+
+		@Override
+		public AnimationType animationType()
+		{
+			return HIDE_COLLAPSE;
+		}
+
+		@Override
+		public boolean isComplete()
+		{
+			return complete;
+		}
+	}
+
+	// =======================================================================
+
+	private class ShowCollapseButton implements Animator
+	{
+		// TODO (?)
+		// Slide image 10px to the left
+		private final double bumpAnimationSpeed = animationSpeed / 10;
+
+		private int keyFrame = 0;
+
+		// ratio to which the image is slid left (0 = all the way right, 1 = all the way left)
+		private double bumpRatio = 0;
+
+		private boolean complete = false;
+
+		@Override
+		public PaintState animate( final long delta_time )
+		{
+			if ( keyFrame == 0 )
+			{
+				// Slide image to the left
+				bumpRatio += bumpAnimationSpeed * delta_time;
+				bumpRatio = Math.min( 1, Math.max( 0, bumpRatio ) );
+				if ( bumpRatio == 1 )
+					keyFrame = 1;
+
+			}
+			else if ( keyFrame == 1 )
+			{
+				// Slide image back to the initial position
+				bumpRatio -= bumpAnimationSpeed * delta_time;
+				bumpRatio = Math.min( 1, Math.max( 0, bumpRatio ) );
+				if ( bumpRatio == 0 )
+					keyFrame = 2;
+			}
+
+			// Fade-in
+			updateAlpha( true, delta_time );
+
+			complete = keyFrame >= 2;
+
+			return new PaintState( 1, 1, cos( bumpRatio ), alpha );
+		}
+
+		@Override
+		public AnimationType animationType()
+		{
+			return SHOW_COLLAPSE;
+		}
+
+		@Override
+		public boolean isComplete()
+		{
+			return complete;
+		}
+	}
+
+	// =======================================================================
+
+	private class HideExpandButton implements Animator
+	{
+		// TODO (?)
+		private final double expandAnimationSpeed = animationSpeed / imgw;
+
+		// ratio to which the image is expanded (0 = hidden, 1 = fully expanded)
+		private double expandRatio;
+
+		private boolean complete = false;
+
+		public HideExpandButton( Animator currentAnimator )
+		{
+			if ( currentAnimator != null && !currentAnimator.isComplete() && currentAnimator instanceof ShowExpandButton )
+				// if an incomplete ShowExpandButton animation is running, initialize expandRatio to match
+				expandRatio = ( ( ShowExpandButton ) currentAnimator ).expandRatio;
+			else
+				// otherwise start from fully expanded image
+				expandRatio = 1;
+		}
+
+		@Override
+		public PaintState animate( final long delta_time )
+		{
+
+			// Speed up animation by factor 2
+			expandRatio -= 2 * expandAnimationSpeed * delta_time;
+			expandRatio = Math.min( 1, Math.max( 0, expandRatio ) );
+
+			// Fade-out
+			updateAlpha( false, delta_time );
+
+			complete = expandRatio <= 0;
+
+			final double imgRatio = cos( expandRatio );
+			return new PaintState( imgRatio, imgRatio, 0, alpha );
+		}
+
+		@Override
+		public AnimationType animationType()
+		{
+			return HIDE_EXPAND;
+		}
+
+		@Override
+		public boolean isComplete()
+		{
+			return complete;
+		}
+	}
+
+	// =======================================================================
+
+	private class ShowExpandButton implements Animator
+	{
+		// TODO (?)
+		private final double expandAnimationSpeed = animationSpeed / imgw;
+
+		private int keyFrame;
+
+		// ratio to which the image is expanded (0 = hidden, 1 = fully expanded)
+		private double expandRatio;
+
+		private boolean complete = false;
+
+		public ShowExpandButton( Animator currentAnimator )
+		{
+			if ( currentAnimator != null && !currentAnimator.isComplete() && currentAnimator instanceof HideExpandButton )
+				// if an incomplete HideExpandButton animation is running, initialize expandRatio to match
+				expandRatio = ( ( HideExpandButton ) currentAnimator ).expandRatio;
+			else
+				// otherwise start from fully hidden image
+				expandRatio = 0;
+
+			// initialize keyFrame based on expandRatio
+			if ( expandRatio > 0 )
+				keyFrame = 1;
+			else
+				keyFrame = 0;
+		}
+
+		@Override
+		public PaintState animate( final long delta_time )
+		{
+			// Slide image in
+			if ( keyFrame == 0 )
+			{
+				// Slide image in with doubled speed
+				expandRatio += delta_time * 2 * expandAnimationSpeed;
+				expandRatio = Math.min( 1, Math.max( 0, expandRatio ) );
+				if ( expandRatio == 1 )
+				{
+					keyFrame = 1;
+				}
+			}
+			else if ( keyFrame == 1 )
+			{
+				// Move it half back
+				expandRatio -= delta_time * expandAnimationSpeed;
+				expandRatio = Math.min( 1, Math.max( 0, expandRatio ) );
+				if ( expandRatio <= 0.5 )
+				{
+					keyFrame = 2;
+				}
+			}
+			else if ( keyFrame == 2 )
+			{
+				// And move it again full in
+				expandRatio += delta_time * expandAnimationSpeed;
+				expandRatio = Math.min( 1, Math.max( 0, expandRatio ) );
+				if ( expandRatio == 1 )
+				{
+					keyFrame = 3;
+				}
+			}
+			else
+			{
+				expandRatio = 1;
+			}
+
+			// Fade-in
+			updateAlpha( true, delta_time );
+
+			complete = keyFrame >= 3;
+
+			final double imgRatio = cos( expandRatio );
+			final double bgRatio = keyFrame > 0 ? 1 : imgRatio; // Background should only move out and stay
+			return new PaintState( imgRatio, bgRatio, 0, alpha );
+		}
+
+		@Override
+		public AnimationType animationType()
+		{
+			return SHOW_EXPAND;
+		}
+
+		@Override
+		public boolean isComplete()
+		{
+			return complete;
+		}
+	}
+}
diff --git a/src/main/java/bdv/ui/splitpanel/SplitPaneOneTouchExpandTrigger.java b/src/main/java/bdv/ui/splitpanel/SplitPaneOneTouchExpandTrigger.java
new file mode 100644
index 0000000000000000000000000000000000000000..35c8fdc29cacf62b51906f44901b92bf41c3820a
--- /dev/null
+++ b/src/main/java/bdv/ui/splitpanel/SplitPaneOneTouchExpandTrigger.java
@@ -0,0 +1,142 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.ui.splitpanel;
+
+import bdv.ui.splitpanel.SplitPaneOneTouchExpandAnimator.AnimationType;
+import bdv.viewer.ViewerPanel;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+
+import static bdv.ui.splitpanel.SplitPaneOneTouchExpandAnimator.AnimationType.HIDE_COLLAPSE;
+import static bdv.ui.splitpanel.SplitPaneOneTouchExpandAnimator.AnimationType.HIDE_EXPAND;
+import static bdv.ui.splitpanel.SplitPaneOneTouchExpandAnimator.AnimationType.SHOW_COLLAPSE;
+import static bdv.ui.splitpanel.SplitPaneOneTouchExpandAnimator.AnimationType.SHOW_EXPAND;
+
+class SplitPaneOneTouchExpandTrigger extends MouseAdapter
+{
+	private final SplitPaneOneTouchExpandAnimator animator;
+
+	private final SplitPanel splitPanel;
+
+	private final ViewerPanel viewer;
+
+	public SplitPaneOneTouchExpandTrigger( final SplitPaneOneTouchExpandAnimator animator, final SplitPanel splitPanel, final ViewerPanel viewer )
+	{
+		this.animator = animator;
+		this.splitPanel = splitPanel;
+		this.viewer = viewer;
+	}
+
+	@Override
+	public void mouseMoved( final MouseEvent e )
+	{
+		final int x = e.getX();
+		final int y = e.getY();
+
+		// check whether in border region
+		if ( animator.isInBorderRegion( x, y ) )
+			checkEnterBorderRegion();
+		else
+			checkExitBorderRegion();
+
+		// check whether in trigger region
+		if ( animator.isInTriggerRegion( x, y ) )
+			checkEnterTriggerRegion();
+		else
+			checkExitTriggerRegion();
+	}
+
+	@Override
+	public void mouseExited( final MouseEvent e )
+	{
+		checkExitBorderRegion();
+		checkExitTriggerRegion();
+	}
+
+	@Override
+	public void mouseClicked( final MouseEvent e )
+	{
+		if ( animator.isInTriggerRegion( e.getX(), e.getY() ) )
+		{
+			splitPanel.setCollapsed( !splitPanel.isCollapsed() );
+			viewer.requestRepaint();
+			checkExitBorderRegion();
+			checkExitTriggerRegion();
+		}
+	}
+
+	private boolean inBorderRegion = false;
+	private boolean inTriggerRegion = false;
+
+	private void checkExitTriggerRegion()
+	{
+		if ( inTriggerRegion )
+		{
+			inTriggerRegion = false;
+			if ( !splitPanel.isCollapsed() )
+				startAnimation( HIDE_COLLAPSE );
+		}
+	}
+
+	private void checkEnterTriggerRegion()
+	{
+		if ( !inTriggerRegion )
+		{
+			inTriggerRegion = true;
+			if ( !splitPanel.isCollapsed() )
+				startAnimation( SHOW_COLLAPSE );
+		}
+	}
+
+	private void checkExitBorderRegion()
+	{
+		if ( inBorderRegion )
+		{
+			inBorderRegion = false;
+			if ( splitPanel.isCollapsed() )
+				startAnimation( HIDE_EXPAND );
+		}
+	}
+
+	private void checkEnterBorderRegion()
+	{
+		if ( !inBorderRegion )
+		{
+			inBorderRegion = true;
+			if ( splitPanel.isCollapsed() )
+				startAnimation( SHOW_EXPAND );
+		}
+	}
+
+	private void startAnimation( final AnimationType animationType )
+	{
+		animator.startAnimation( animationType );
+		viewer.getDisplay().repaint();
+	}
+}
diff --git a/src/main/java/bdv/ui/splitpanel/SplitPanel.java b/src/main/java/bdv/ui/splitpanel/SplitPanel.java
new file mode 100644
index 0000000000000000000000000000000000000000..cc4b65bc69d43daafd11906491eabe1b1418f4d0
--- /dev/null
+++ b/src/main/java/bdv/ui/splitpanel/SplitPanel.java
@@ -0,0 +1,205 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.ui.splitpanel;
+
+import bdv.ui.CardPanel;
+import bdv.viewer.ViewerPanel;
+import java.awt.Color;
+import java.awt.Dimension;
+import java.awt.Graphics;
+import java.awt.event.ComponentAdapter;
+import java.awt.event.ComponentEvent;
+import javax.swing.InputMap;
+import javax.swing.JComponent;
+import javax.swing.JScrollPane;
+import javax.swing.JSplitPane;
+import javax.swing.KeyStroke;
+import javax.swing.border.Border;
+import javax.swing.border.EmptyBorder;
+import javax.swing.plaf.basic.BasicSplitPaneDivider;
+import javax.swing.plaf.basic.BasicSplitPaneUI;
+import org.scijava.ui.behaviour.io.InputTriggerConfig;
+import org.scijava.ui.behaviour.util.Actions;
+
+/**
+ * A {@code JSplitPane} with a {@code ViewerPanel} on the left and a
+ * {@code CardPanel} on the right. Animated arrows are added to the
+ * {@code ViewerPanel}, such that the right ({@code CardPanel}) pane can be
+ * fully collapsed or exanded. The {@code CardPanel} can be also
+ * programmatically collapsed or exanded using {@link #setCollapsed(boolean)}.
+ *
+ * @author Tim-Oliver Buchholz
+ * @author Tobias Pietzsch
+ */
+public class SplitPanel extends JSplitPane
+{
+	private static final int DEFAULT_DIVIDER_SIZE = 3;
+
+	private static final String FOCUS_VIEWER_PANEL = "focus viewer panel";
+	private static final String HIDE_CARD_PANEL = "hide card panel";
+
+	private final JScrollPane scrollPane;
+
+	private int width;
+
+	private final SplitPaneOneTouchExpandAnimator oneTouchExpandAnimator;
+
+	public SplitPanel( final ViewerPanel viewerPanel, final CardPanel cardPanel )
+	{
+		super( JSplitPane.HORIZONTAL_SPLIT );
+
+		configureSplitPane();
+
+		final JComponent cardPanelComponent = cardPanel.getComponent();
+		scrollPane = new JScrollPane( cardPanelComponent );
+		scrollPane.setBorder( new EmptyBorder( 0, 0, 0, 0 ) );
+		scrollPane.setHorizontalScrollBarPolicy( JScrollPane.HORIZONTAL_SCROLLBAR_NEVER );
+		scrollPane.setPreferredSize( new Dimension( 800, 200 ) );
+
+		final InputMap inputMap = scrollPane.getInputMap( JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT );
+		inputMap.put( KeyStroke.getKeyStroke( "F6" ), "none" );
+
+		final InputTriggerConfig inputTriggerConfig = viewerPanel.getOptionValues().getInputTriggerConfig();
+		final Actions actions = new Actions( inputMap, scrollPane.getActionMap(), inputTriggerConfig, "bdv" );
+		actions.runnableAction( viewerPanel::requestFocusInWindow, FOCUS_VIEWER_PANEL, "ESCAPE" );
+		actions.runnableAction( () -> {
+			setCollapsed( true );
+			viewerPanel.requestFocusInWindow();
+		}, HIDE_CARD_PANEL, "shift ESCAPE" );
+
+		setLeftComponent( viewerPanel );
+		setRightComponent( null );
+		setBorder( null );
+		setPreferredSize( viewerPanel.getPreferredSize() );
+
+		super.setDividerSize( 0 );
+
+		oneTouchExpandAnimator = new SplitPaneOneTouchExpandAnimator( this::isCollapsed );
+		viewerPanel.addOverlayAnimator( oneTouchExpandAnimator );
+
+		final SplitPaneOneTouchExpandTrigger oneTouchExpandTrigger = new SplitPaneOneTouchExpandTrigger( oneTouchExpandAnimator, this, viewerPanel );
+		viewerPanel.getDisplay().addMouseMotionListener( oneTouchExpandTrigger );
+		viewerPanel.getDisplay().addMouseListener( oneTouchExpandTrigger );
+
+		setDividerSize( DEFAULT_DIVIDER_SIZE );
+
+		addComponentListener( new ComponentAdapter()
+		{
+			@Override
+			public void componentResized( final ComponentEvent e )
+			{
+				final int w = getWidth();
+				if ( width > 0 )
+				{
+					final int dl = getLastDividerLocation() + w - width;
+					setLastDividerLocation( Math.max( 50, dl ) );
+				}
+				else
+				{
+					// When the component is first made visible, set LastDividerLocation to a reasonable value
+					setDividerLocation( w );
+					setLastDividerLocation( Math.max( w / 2, w - Math.max( 200, cardPanelComponent.getPreferredSize().width ) ) );
+				}
+				width = w;
+			}
+		} );
+	}
+
+	private void configureSplitPane()
+	{
+		this.setUI( new BasicSplitPaneUI()
+		{
+			@Override
+			public BasicSplitPaneDivider createDefaultDivider()
+			{
+				return new BasicSplitPaneDivider( this )
+				{
+					private static final long serialVersionUID = 1L;
+
+					@Override
+					public void paint( final Graphics g )
+					{
+						g.setColor( Color.white );
+						g.fillRect( 0, 0, getSize().width, getSize().height );
+						super.paint( g );
+					}
+
+					@Override
+					public void setBorder( final Border border )
+					{
+						super.setBorder( null );
+					}
+				};
+			}
+		} );
+		this.setForeground( Color.white );
+		this.setBackground( Color.white );
+		this.setResizeWeight( 1.0 );
+		this.setContinuousLayout( true );
+	}
+
+	// divider size set externally
+	private int dividerSizeWhenVisible = DEFAULT_DIVIDER_SIZE;
+
+	@Override
+	public void setDividerSize( final int newSize )
+	{
+		dividerSizeWhenVisible = newSize;
+	}
+
+	/**
+	 * Un/collapse the UI-Panel.
+	 */
+	public void setCollapsed( final boolean collapsed )
+	{
+		if ( isCollapsed() == collapsed )
+			return;
+
+		oneTouchExpandAnimator.clearPaintState();
+		if ( collapsed )
+		{
+			setRightComponent( null );
+			super.setDividerSize( 0 );
+			setDividerLocation( 1.0d );
+		}
+		else
+		{
+			setRightComponent( scrollPane );
+			super.setDividerSize( dividerSizeWhenVisible );
+			final int dl = getLastDividerLocation();
+			final int w = getWidth();
+			setDividerLocation( Math.max( Math.min ( w / 2, 50 ), Math.min( w - 50, dl ) ) );
+		}
+	}
+
+	public boolean isCollapsed()
+	{
+		return getRightComponent() == null;
+	}
+}
diff --git a/src/main/java/bdv/ui/viewermodepanel/DisplaySettingsPanel.java b/src/main/java/bdv/ui/viewermodepanel/DisplaySettingsPanel.java
new file mode 100644
index 0000000000000000000000000000000000000000..e938a923cde97458ec118151566858291cc462c0
--- /dev/null
+++ b/src/main/java/bdv/ui/viewermodepanel/DisplaySettingsPanel.java
@@ -0,0 +1,128 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.ui.viewermodepanel;
+
+import bdv.viewer.DisplayMode;
+import bdv.viewer.Interpolation;
+import bdv.viewer.ViewerState;
+import java.awt.Color;
+import javax.swing.ImageIcon;
+import javax.swing.JPanel;
+import javax.swing.SwingUtilities;
+import net.miginfocom.swing.MigLayout;
+
+import static bdv.viewer.Interpolation.NEARESTNEIGHBOR;
+import static bdv.viewer.Interpolation.NLINEAR;
+import static bdv.viewer.ViewerStateChange.DISPLAY_MODE_CHANGED;
+import static bdv.viewer.ViewerStateChange.INTERPOLATION_CHANGED;
+
+/**
+ * This panel adds buttons to toggle fused, grouped, and
+ * interpolation mode.
+ *
+ * @author Tim-Oliver Buchholz, CSBD/MPI-CBG, Dresden
+ * @author Tobias Pietzsch
+ */
+public class DisplaySettingsPanel extends JPanel
+{
+	private static final String SINGLE_MODE_TOOL_TIP = "<html><b>Single</b>/Fused</html>";
+	private static final String FUSED_MODE_TOOL_TIP = "<html>Single/<b>Fused</b></html>";
+	private static final String GROUP_MODE_TOOL_TIP = "<html>Source/<b>Group</b></html>";
+	private static final String SOURCE_MODE_TOOL_TIP = "<html><b>Source</b>/Group</html>";
+	private static final String NEAREST_INTERPOLATION_TOOL_TIP = "<html><b>Nearest</b>/Linear</html>";
+	private static final String LINEAR_INTERPOLATION_TOOL_TIP = "<html>Nearest/<b>Linear</b></html>";
+
+	private final LabeledToggleButton fusion;
+	private final LabeledToggleButton grouping;
+	private final LabeledToggleButton interpolation;
+
+	public DisplaySettingsPanel( final ViewerState state )
+	{
+		super( new MigLayout( "ins 0, fillx, filly", "[][][]", "top" ) );
+		this.setBackground( Color.white );
+
+		fusion = new LabeledToggleButton(
+				new ImageIcon( this.getClass().getResource( "single_mode.png" ) ),
+				new ImageIcon( this.getClass().getResource( "fusion_mode.png" ) ),
+				"Single",
+				"Fused",
+				SINGLE_MODE_TOOL_TIP,
+				FUSED_MODE_TOOL_TIP );
+		grouping = new LabeledToggleButton(
+				new ImageIcon( this.getClass().getResource( "source_mode.png" ) ),
+				new ImageIcon( this.getClass().getResource( "grouping_mode.png" ) ),
+				"Source",
+				"Group",
+				SOURCE_MODE_TOOL_TIP,
+				GROUP_MODE_TOOL_TIP );
+		interpolation = new LabeledToggleButton(
+				new ImageIcon( this.getClass().getResource( "nearest.png" ) ),
+				new ImageIcon( this.getClass().getResource( "linear.png" ) ),
+				"Nearest",
+				"Linear",
+				NEAREST_INTERPOLATION_TOOL_TIP,
+				LINEAR_INTERPOLATION_TOOL_TIP );
+
+		fusion.setSelected( state.getDisplayMode().hasFused() );
+		grouping.setSelected( state.getDisplayMode().hasGrouping() );
+		interpolation.setSelected( state.getInterpolation() == NLINEAR );
+
+		state.changeListeners().add( e -> {
+			if ( e == DISPLAY_MODE_CHANGED )
+			{
+				final DisplayMode displayMode = state.getDisplayMode();
+				SwingUtilities.invokeLater( () -> {
+					fusion.setSelected( displayMode.hasFused() );
+					grouping.setSelected( displayMode.hasGrouping() );
+				} );
+			}
+			else if ( e == INTERPOLATION_CHANGED )
+			{
+				final Interpolation interpolationMode = state.getInterpolation();
+				SwingUtilities.invokeLater( () -> interpolation.setSelected( interpolationMode == NLINEAR ) );
+			}
+		} );
+
+		fusion.addActionListener( e -> {
+			state.setDisplayMode( state.getDisplayMode().withFused( fusion.isSelected() ) );
+		} );
+
+		grouping.addActionListener( e -> {
+			state.setDisplayMode( state.getDisplayMode().withGrouping( grouping.isSelected() ) );
+		} );
+
+		interpolation.addActionListener( e -> {
+			state.setInterpolation( interpolation.isSelected() ? NLINEAR : NEARESTNEIGHBOR );
+		} );
+
+		this.add( fusion );
+		this.add( grouping );
+		this.add( interpolation );
+	}
+}
diff --git a/src/main/java/bdv/ui/viewermodepanel/LabeledToggleButton.java b/src/main/java/bdv/ui/viewermodepanel/LabeledToggleButton.java
new file mode 100644
index 0000000000000000000000000000000000000000..1cf8b1ddc119df7eb2adb1463d9335647d5bb77d
--- /dev/null
+++ b/src/main/java/bdv/ui/viewermodepanel/LabeledToggleButton.java
@@ -0,0 +1,70 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.ui.viewermodepanel;
+
+import java.awt.Font;
+import javax.swing.Icon;
+import javax.swing.JLabel;
+
+class LabeledToggleButton extends ToggleButton
+{
+	private final String text;
+	private final String selectedText;
+
+	private final JLabel label;
+
+	public LabeledToggleButton(
+			final Icon icon,
+			final Icon selectedIcon,
+			final String text,
+			final String selectedText,
+			final String tooltipText,
+			final String selectedTooltipText )
+	{
+		super( icon, selectedIcon, tooltipText, selectedTooltipText );
+		this.text = text;
+		this.selectedText = selectedText;
+
+		label = new JLabel( text );
+		setFont( label );
+
+		this.add( label, "center" );
+	}
+
+	public void setSelected( final boolean selected )
+	{
+		super.setSelected( selected );
+		label.setText( selected ? selectedText : text );
+	}
+
+	private void setFont( final JLabel label )
+	{
+		label.setFont( new Font( Font.MONOSPACED, Font.BOLD, 9 ) );
+	}
+}
diff --git a/src/main/java/bdv/ui/viewermodepanel/ToggleButton.java b/src/main/java/bdv/ui/viewermodepanel/ToggleButton.java
new file mode 100644
index 0000000000000000000000000000000000000000..042fce75405f429c587b23e0bcebb9a832aeb91a
--- /dev/null
+++ b/src/main/java/bdv/ui/viewermodepanel/ToggleButton.java
@@ -0,0 +1,93 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.ui.viewermodepanel;
+
+import java.awt.Color;
+import java.awt.Dimension;
+import java.awt.event.ActionListener;
+import javax.swing.Icon;
+import javax.swing.JPanel;
+import javax.swing.JToggleButton;
+import net.miginfocom.swing.MigLayout;
+
+class ToggleButton extends JPanel
+{
+	private final String tooltipText;
+	private final String selectedTooltipText;
+
+	private final JToggleButton button;
+
+	public ToggleButton(
+			final Icon icon,
+			final Icon selectedIcon,
+			final String tooltipText,
+			final String selectedTooltipText )
+	{
+		super( new MigLayout( "ins 0, fillx, filly", "[]", "[]0lp![]" ) );
+		this.tooltipText = tooltipText;
+		this.selectedTooltipText = selectedTooltipText;
+
+		button = new JToggleButton( icon );
+		button.setSelectedIcon( selectedIcon );
+		setLook( button );
+
+		this.setBackground( Color.white );
+		this.add( button, "growx, center, wrap" );
+	}
+
+	public void setSelected( final boolean selected )
+	{
+		button.setSelected( selected );
+		button.setToolTipText( selected ? selectedTooltipText : tooltipText );
+	}
+
+	public boolean isSelected()
+	{
+		return button.isSelected();
+	}
+
+	public void addActionListener( final ActionListener l )
+	{
+		button.addActionListener( l );
+	}
+
+	public void removeActionListener( final ActionListener l )
+	{
+		button.removeActionListener( l );
+	}
+
+	private void setLook( final JToggleButton button )
+	{
+		button.setMaximumSize( new Dimension( button.getIcon().getIconWidth(), button.getIcon().getIconHeight() ) );
+		button.setBackground( Color.white );
+		button.setBorderPainted( false );
+		button.setFocusPainted( false );
+		button.setContentAreaFilled( false );
+	}
+}
diff --git a/src/main/java/bdv/util/AWTUtils.java b/src/main/java/bdv/util/AWTUtils.java
new file mode 100644
index 0000000000000000000000000000000000000000..3c1b1a4b099d09ca118175a640516b6028e97511
--- /dev/null
+++ b/src/main/java/bdv/util/AWTUtils.java
@@ -0,0 +1,113 @@
+/*
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.util;
+
+import java.awt.GraphicsConfiguration;
+import java.awt.GraphicsDevice;
+import java.awt.GraphicsEnvironment;
+import java.awt.Transparency;
+import java.awt.image.BufferedImage;
+import java.awt.image.ColorModel;
+import java.awt.image.DataBuffer;
+import java.awt.image.DirectColorModel;
+import java.awt.image.Raster;
+import java.awt.image.SampleModel;
+import java.awt.image.WritableRaster;
+import net.imglib2.display.screenimage.awt.ARGBScreenImage;
+
+/**
+ * Static helper methods for setting up {@link GraphicsConfiguration} and
+ * {@link BufferedImage BufferedImages}.
+ *
+ * @author Tobias Pietzsch
+ */
+public class AWTUtils
+{
+	/**
+	 * Get a {@link GraphicsConfiguration} from the default screen
+	 * {@link GraphicsDevice} that matches the
+	 * {@link ColorModel#getTransparency() transparency} of the given
+	 * <code>colorModel</code>. If no matching configuration is found, the
+	 * default configuration of the {@link GraphicsDevice} is returned.
+	 */
+	public static GraphicsConfiguration getSuitableGraphicsConfiguration( final ColorModel colorModel )
+	{
+		final GraphicsDevice device = GraphicsEnvironment.getLocalGraphicsEnvironment().getDefaultScreenDevice();
+		final GraphicsConfiguration defaultGc = device.getDefaultConfiguration();
+
+		final int transparency = colorModel.getTransparency();
+
+		if ( defaultGc.getColorModel( transparency ).equals( colorModel ) )
+			return defaultGc;
+
+		for ( final GraphicsConfiguration gc : device.getConfigurations() )
+			if ( gc.getColorModel( transparency ).equals( colorModel ) )
+				return gc;
+
+		return defaultGc;
+	}
+
+	public static final ColorModel ARGB_COLOR_MODEL = new DirectColorModel( 32, 0xff0000, 0xff00, 0xff, 0xff000000 );
+
+	public static final ColorModel RGB_COLOR_MODEL = new DirectColorModel( 24, 0xff0000, 0xff00, 0xff );
+
+	/**
+	 * Get a {@link BufferedImage} for the given {@link ARGBScreenImage}.
+	 *
+	 * @param screenImage
+	 * 		the image.
+	 * @param discardAlpha
+	 * 		Whether to discard the <code>screenImage</code> alpha
+	 * 		components when drawing.
+	 */
+	public static BufferedImage getBufferedImage( final ARGBScreenImage screenImage, final boolean discardAlpha )
+	{
+		final BufferedImage si = screenImage.image();
+		if ( discardAlpha && ( si.getTransparency() != Transparency.OPAQUE ) )
+		{
+			final SampleModel sampleModel = RGB_COLOR_MODEL.createCompatibleWritableRaster( 1, 1 ).getSampleModel().createCompatibleSampleModel( si.getWidth(), si.getHeight() );
+			final DataBuffer dataBuffer = si.getRaster().getDataBuffer();
+			final WritableRaster rgbRaster = Raster.createWritableRaster( sampleModel, dataBuffer, null );
+			return new BufferedImage( RGB_COLOR_MODEL, rgbRaster, false, null );
+		}
+		return si;
+	}
+
+	/**
+	 * Get a {@link BufferedImage} for the given {@link ARGBScreenImage}.
+	 * Discard the <code>screenImage</code> alpha components when drawing.
+	 *
+	 * @param screenImage
+	 * 		the image.
+	 */
+	public static BufferedImage getBufferedImage( final ARGBScreenImage screenImage )
+	{
+		return getBufferedImage( screenImage, true );
+	}
+}
diff --git a/src/main/java/bdv/util/Affine3DHelpers.java b/src/main/java/bdv/util/Affine3DHelpers.java
index 28b572920866072b3e590cd888b2e31cff0f715a..d0aca79f3d40102c2e7f1fac33778e57432c8f1b 100644
--- a/src/main/java/bdv/util/Affine3DHelpers.java
+++ b/src/main/java/bdv/util/Affine3DHelpers.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -37,7 +36,7 @@ import net.imglib2.util.LinAlgHelpers;
  * {@link AffineTransform3D}. Note that most of these helpers assume additional
  * restrictions on the affine transform.
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
 public class Affine3DHelpers
 {
@@ -200,8 +199,21 @@ public class Affine3DHelpers
 		}
 		return Math.sqrt( sqSum );
 	}
-	
-	
+
+	/**
+	 * Compare two transforms for equality.
+	 *
+	 * @return {@code true} iff {@code t1} and {@code t2} are exactly the same
+	 */
+	public static boolean equals( AffineTransform3D t1, AffineTransform3D t2 )
+	{
+		for ( int r = 0; r < 3; ++r )
+			for ( int c = 0; c < 4; ++c )
+				if ( t1.get( r, c ) != t2.get( r, c ) )
+					return false;
+		return true;
+	}
+
 	/**
 	 * Pretty-print the matrix content of an affine transform.
 	 * 
diff --git a/src/main/java/bdv/util/BoundedInterval.java b/src/main/java/bdv/util/BoundedInterval.java
index 601ae514f5fd6cb13e1fbe3db8c2498e74c4e2de..63c728aa7da996f916af4cb14141d3f871116824 100644
--- a/src/main/java/bdv/util/BoundedInterval.java
+++ b/src/main/java/bdv/util/BoundedInterval.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -36,7 +35,7 @@ package bdv.util;
  * {@link BoundedValue#setUpdateListener(UpdateListener) minimum and maximum}
  * values and/or overriding the {@link #updateInterval(int, int)} method.
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
 public class BoundedInterval
 {
diff --git a/src/main/java/bdv/util/BoundedIntervalDouble.java b/src/main/java/bdv/util/BoundedIntervalDouble.java
index 0837d36962c0478228f0cad7026d7298253fce3b..1257c1cfc21764225a7e5728ca13cc2e173d618e 100644
--- a/src/main/java/bdv/util/BoundedIntervalDouble.java
+++ b/src/main/java/bdv/util/BoundedIntervalDouble.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -36,7 +35,7 @@ package bdv.util;
  * {@link BoundedValueDouble#setUpdateListener(UpdateListener) minimum and maximum}
  * values and/or overriding the {@link #updateInterval(double, double)} method.
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
 public class BoundedIntervalDouble
 {
diff --git a/src/main/java/bdv/util/BoundedRange.java b/src/main/java/bdv/util/BoundedRange.java
new file mode 100644
index 0000000000000000000000000000000000000000..3f0292c69eda8a6f2078d5fef243d926e774cccd
--- /dev/null
+++ b/src/main/java/bdv/util/BoundedRange.java
@@ -0,0 +1,173 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.util;
+
+/**
+ * A bounded range with {@code minBound <= min <= max <= maxBound}.
+ * <p>
+ * {@code BoundedRang} is immutable.
+ * <p>
+ * {@link #withMin(double)}, {@link #withMinBound(double)} etc derive a new
+ * {@code BoundedRange} with the given {@code min}, {@code minBound} etc, while
+ * maintaining the {@code minBound <= min <= max <= maxBound} property. For
+ * example, {@code this.withMin(x)}, will also update {@code max}, if
+ * {@code x > this.getMax()}.
+ * <p>
+ * {@link #join(BoundedRange) join(other)} will derive a new
+ * {@code BoundedRange}, with {@code min} the minimum of {@code this.getMin()}
+ * and {@code other.getMin()} and so on.
+ *
+ * @author Tobias Pietzsch
+ */
+public final class BoundedRange
+{
+	private final double minBound;
+	private final double maxBound;
+	private final double min;
+	private final double max;
+
+	public BoundedRange( final double minBound, final double maxBound, final double min, final double max )
+	{
+		if ( ( minBound > min ) || ( maxBound < max ) || ( min > max ) )
+			throw new IllegalArgumentException();
+
+		this.minBound = minBound;
+		this.maxBound = maxBound;
+		this.min = min;
+		this.max = max;
+	}
+
+	public double getMinBound()
+	{
+		return minBound;
+	}
+
+	public double getMaxBound()
+	{
+		return maxBound;
+	}
+
+	public Bounds getBounds()
+	{
+		return new Bounds( minBound, maxBound );
+	}
+
+	public double getMin()
+	{
+		return min;
+	}
+
+	public double getMax()
+	{
+		return max;
+	}
+
+	public BoundedRange withMax( final double newMax )
+	{
+		final double newMin = Math.min( min, newMax );
+		final double newMinBound = Math.min( minBound, newMin );
+		final double newMaxBound = Math.max( maxBound, newMax );
+		return new BoundedRange( newMinBound, newMaxBound, newMin, newMax );
+	}
+
+	public BoundedRange withMin( final double newMin )
+	{
+		final double newMax = Math.max( max, newMin );
+		final double newMinBound = Math.min( minBound, newMin );
+		final double newMaxBound = Math.max( maxBound, newMax );
+		return new BoundedRange( newMinBound, newMaxBound, newMin, newMax );
+	}
+
+	public BoundedRange withMaxBound( final double newMaxBound )
+	{
+		final double newMinBound = Math.min( minBound, newMaxBound );
+		final double newMin = Math.min( Math.max( min, newMinBound ), newMaxBound );
+		final double newMax = Math.min( Math.max( max, newMinBound ), newMaxBound );
+		return new BoundedRange( newMinBound, newMaxBound, newMin, newMax );
+	}
+
+	public BoundedRange withMinBound( final double newMinBound )
+	{
+		final double newMaxBound = Math.max( maxBound, newMinBound );
+		final double newMin = Math.min( Math.max( min, newMinBound ), newMaxBound );
+		final double newMax = Math.min( Math.max( max, newMinBound ), newMaxBound );
+		return new BoundedRange( newMinBound, newMaxBound, newMin, newMax );
+	}
+
+	public BoundedRange join( final BoundedRange other )
+	{
+		final double newMinBound = Math.min( minBound, other.minBound );
+		final double newMaxBound = Math.max( maxBound, other.maxBound );
+		final double newMin = Math.min( min, other.min );
+		final double newMax = Math.max( max, other.max );
+		return new BoundedRange( newMinBound, newMaxBound, newMin, newMax );
+	}
+
+	@Override
+	public String toString()
+	{
+		return "BoundedRange[ (" + minBound + ") " + min + ", " + max + " (" + maxBound + ") ]";
+	}
+
+	@Override
+	public boolean equals( final Object o )
+	{
+		if ( this == o )
+			return true;
+		if ( o == null || getClass() != o.getClass() )
+			return false;
+
+		final BoundedRange that = ( BoundedRange ) o;
+
+		if ( Double.compare( that.minBound, minBound ) != 0 )
+			return false;
+		if ( Double.compare( that.maxBound, maxBound ) != 0 )
+			return false;
+		if ( Double.compare( that.min, min ) != 0 )
+			return false;
+		return Double.compare( that.max, max ) == 0;
+	}
+
+	@Override
+	public int hashCode()
+	{
+		int result;
+		long temp;
+		temp = Double.doubleToLongBits( minBound );
+		result = ( int ) ( temp ^ ( temp >>> 32 ) );
+		temp = Double.doubleToLongBits( maxBound );
+		result = 31 * result + ( int ) ( temp ^ ( temp >>> 32 ) );
+		temp = Double.doubleToLongBits( min );
+		result = 31 * result + ( int ) ( temp ^ ( temp >>> 32 ) );
+		temp = Double.doubleToLongBits( max );
+		result = 31 * result + ( int ) ( temp ^ ( temp >>> 32 ) );
+		return result;
+	}
+}
+
diff --git a/src/main/java/bdv/util/BoundedValue.java b/src/main/java/bdv/util/BoundedValue.java
index cd50c6748d7138d1148d93509d6802f07cbcaf03..5ef692507f13517d940f95f35c8bd42be782e564 100644
--- a/src/main/java/bdv/util/BoundedValue.java
+++ b/src/main/java/bdv/util/BoundedValue.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -34,7 +33,7 @@ package bdv.util;
  * {@link #setUpdateListener(UpdateListener) listener} is notified when the
  * value or its allowed range is changed.
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
 public class BoundedValue
 {
@@ -46,7 +45,7 @@ public class BoundedValue
 
 	public interface UpdateListener
 	{
-		public void update();
+		void update();
 	}
 
 	private UpdateListener updateListener;
diff --git a/src/main/java/bdv/util/BoundedValueDouble.java b/src/main/java/bdv/util/BoundedValueDouble.java
index a684f97a6853b017cc0639ed57bd4be482adefd7..369072ab93ae74d94929e313bc679b485272c42f 100644
--- a/src/main/java/bdv/util/BoundedValueDouble.java
+++ b/src/main/java/bdv/util/BoundedValueDouble.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -34,7 +33,7 @@ package bdv.util;
  * {@link #setUpdateListener(UpdateListener) listener} is notified when the
  * value or its allowed range is changed.
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
 public class BoundedValueDouble
 {
@@ -46,7 +45,7 @@ public class BoundedValueDouble
 
 	public interface UpdateListener
 	{
-		public void update();
+		void update();
 	}
 
 	private UpdateListener updateListener;
diff --git a/src/main/java/bdv/util/Bounds.java b/src/main/java/bdv/util/Bounds.java
new file mode 100644
index 0000000000000000000000000000000000000000..7238046d48ef2f4c358acd63337d15a1e9a7c04a
--- /dev/null
+++ b/src/main/java/bdv/util/Bounds.java
@@ -0,0 +1,103 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.util;
+
+/**
+ * A range with {@code minBound <= maxBound}.
+ * <p>
+ * {@link #join(Bounds) join(other)} will derive a new {@code Bounds}, with
+ * {@code minBound} the minimum of {@code this.getMinBound()} and
+ * {@code other.getMinBound()} and so on.
+ *
+ * @author Tobias Pietzsch
+ */
+public final class Bounds
+{
+	private final double minBound;
+	private final double maxBound;
+
+	public Bounds( final double minBound, final double maxBound )
+	{
+		if ( minBound > maxBound )
+			throw new IllegalArgumentException();
+
+		this.minBound = minBound;
+		this.maxBound = maxBound;
+	}
+
+	public double getMinBound()
+	{
+		return minBound;
+	}
+
+	public double getMaxBound()
+	{
+		return maxBound;
+	}
+
+	public Bounds join( final Bounds other )
+	{
+		final double newMinBound = Math.min( minBound, other.minBound );
+		final double newMaxBound = Math.max( maxBound, other.maxBound );
+		return new Bounds( newMinBound, newMaxBound );
+	}
+
+	@Override
+	public String toString()
+	{
+		return "Bounds[ " + minBound + ", " + maxBound + " ]";
+	}
+
+	@Override
+	public boolean equals( final Object o )
+	{
+		if ( this == o )
+			return true;
+		if ( o == null || getClass() != o.getClass() )
+			return false;
+
+		final Bounds that = ( Bounds ) o;
+
+		if ( Double.compare( that.minBound, minBound ) != 0 )
+			return false;
+		return Double.compare( that.maxBound, maxBound ) == 0;
+	}
+
+	@Override
+	public int hashCode()
+	{
+		int result;
+		long temp;
+		temp = Double.doubleToLongBits( minBound );
+		result = ( int ) ( temp ^ ( temp >>> 32 ) );
+		temp = Double.doubleToLongBits( maxBound );
+		result = 31 * result + ( int ) ( temp ^ ( temp >>> 32 ) );
+		return result;
+	}
+}
diff --git a/src/main/java/bdv/util/ConstantRandomAccessible.java b/src/main/java/bdv/util/ConstantRandomAccessible.java
index a31de68d7a3c6d8b3bebb3d45a68ca87d2483af7..c62a71041b919c99de609a35037faca71f648bb5 100644
--- a/src/main/java/bdv/util/ConstantRandomAccessible.java
+++ b/src/main/java/bdv/util/ConstantRandomAccessible.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/util/DelayedPackDialog.java b/src/main/java/bdv/util/DelayedPackDialog.java
new file mode 100644
index 0000000000000000000000000000000000000000..18278a2b41eb29a36f0fdbb757188d4c7f8ebd42
--- /dev/null
+++ b/src/main/java/bdv/util/DelayedPackDialog.java
@@ -0,0 +1,68 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.util;
+
+import javax.swing.*;
+import java.awt.*;
+
+/**
+ * A {@code JDialog} that delays {@code pack()} calls until the dialog is made visible.
+ */
+public class DelayedPackDialog extends JDialog
+{
+	private volatile boolean packIsPending = false;
+
+	public DelayedPackDialog( Frame owner, String title, boolean modal )
+	{
+		super( owner, title, modal );
+	}
+
+	@Override
+	public void pack()
+	{
+		if ( isVisible() )
+		{
+			packIsPending = false;
+			super.pack();
+		}
+		else
+			packIsPending = true;
+	}
+
+	@Override
+	public void setVisible( boolean visible )
+	{
+		if ( visible && packIsPending )
+		{
+			packIsPending = false;
+			super.pack();
+		}
+		super.setVisible( visible );
+	}
+}
diff --git a/src/main/java/bdv/util/DumpInputConfig.java b/src/main/java/bdv/util/DumpInputConfig.java
index 2a63818e141b00968d42ad651ffaedc53dab8e9f..2daa36bf6332f9eba2d9056b80f8c2a23e3e0aa3 100644
--- a/src/main/java/bdv/util/DumpInputConfig.java
+++ b/src/main/java/bdv/util/DumpInputConfig.java
@@ -1,9 +1,8 @@
 /*-
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/util/IntervalBoundingBox.java b/src/main/java/bdv/util/IntervalBoundingBox.java
index beabc8b433251be1af84b894198eefa01d60ecf8..d4903e01e2870c533a6a7825c33273685bb1db3b 100644
--- a/src/main/java/bdv/util/IntervalBoundingBox.java
+++ b/src/main/java/bdv/util/IntervalBoundingBox.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/util/InvokeOnEDT.java b/src/main/java/bdv/util/InvokeOnEDT.java
index 92f0885bbdc2d4eae1b387ae5a0377cc6a309e69..ecf5209aa8e0987dc25f65e677f3a3a560d2fabd 100644
--- a/src/main/java/bdv/util/InvokeOnEDT.java
+++ b/src/main/java/bdv/util/InvokeOnEDT.java
@@ -1,9 +1,8 @@
 /*-
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/util/MipmapTransforms.java b/src/main/java/bdv/util/MipmapTransforms.java
index df802e2cdbd54db7a16da74d951505430f96d5e0..797e92bf996470a297b45884c282e44c80b1ed7c 100644
--- a/src/main/java/bdv/util/MipmapTransforms.java
+++ b/src/main/java/bdv/util/MipmapTransforms.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/util/ModifiableInterval.java b/src/main/java/bdv/util/ModifiableInterval.java
index 58ee1c3f1f4ee3d1b980d48ad09df424f34c70cc..a61a12e3a88a9d53484082f126777605207bacef 100644
--- a/src/main/java/bdv/util/ModifiableInterval.java
+++ b/src/main/java/bdv/util/ModifiableInterval.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/util/ModifiableRealInterval.java b/src/main/java/bdv/util/ModifiableRealInterval.java
index 4deaad294ab371fd7ebbf4702d7c7b57ae4bb3f3..ace0bbfd142bd23bcfd97f25f46a1c741d61036b 100644
--- a/src/main/java/bdv/util/ModifiableRealInterval.java
+++ b/src/main/java/bdv/util/ModifiableRealInterval.java
@@ -1,3 +1,31 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
 package bdv.util;
 
 import net.imglib2.AbstractRealInterval;
diff --git a/src/main/java/bdv/util/MovingAverage.java b/src/main/java/bdv/util/MovingAverage.java
new file mode 100644
index 0000000000000000000000000000000000000000..f3a4d3383f54f5d896b90539746ee637fc6c8fad
--- /dev/null
+++ b/src/main/java/bdv/util/MovingAverage.java
@@ -0,0 +1,71 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.util;
+
+import java.util.Arrays;
+
+/**
+ * Maintains a moving average over the last {@code width} values {@link #add
+ * added}. The average can be {@link #init initialized} to some value (or starts
+ * as 0, i.e., as if {@code width} 0 values had been added)
+ */
+public class MovingAverage
+{
+	private final double[] values;
+
+	private final int width;
+
+	private int index = 0;
+
+	private double average;
+
+	public MovingAverage( final int width )
+	{
+		values = new double[ width ];
+		this.width = width;
+	}
+
+	public void init( final double initialValue )
+	{
+		Arrays.fill( values, initialValue );
+		average = initialValue;
+	}
+
+	public void add( final double value )
+	{
+		average = average + ( value - values[ index ] ) / width;
+		values[ index ] = value;
+		index = ( index + 1 ) % width;
+	}
+
+	public double getAverage()
+	{
+		return average;
+	}
+}
diff --git a/src/main/java/bdv/util/PlaceHolderConverterSetup.java b/src/main/java/bdv/util/PlaceHolderConverterSetup.java
index 9edc1f25f2691b81d9160fa5a855dabf64eeb881..e6a4ba1ab5bc2b5bcbbc8b831f4e8e56f4bc22e8 100644
--- a/src/main/java/bdv/util/PlaceHolderConverterSetup.java
+++ b/src/main/java/bdv/util/PlaceHolderConverterSetup.java
@@ -1,10 +1,36 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
 package bdv.util;
 
-import java.util.ArrayList;
-
 import bdv.tools.brightness.ConverterSetup;
-import bdv.viewer.RequestRepaint;
 import net.imglib2.type.numeric.ARGBType;
+import org.scijava.listeners.Listeners;
 
 public final class PlaceHolderConverterSetup implements ConverterSetup
 {
@@ -18,14 +44,7 @@ public final class PlaceHolderConverterSetup implements ConverterSetup
 
 	private final boolean supportsColor;
 
-	private RequestRepaint viewer;
-
-	private final ArrayList< SetupChangeListener > listeners;
-
-	public interface SetupChangeListener
-	{
-		void setupParametersChanged();
-	}
+	private final Listeners.List< SetupChangeListener > listeners;
 
 	public PlaceHolderConverterSetup(
 			final int setupId,
@@ -49,8 +68,13 @@ public final class PlaceHolderConverterSetup implements ConverterSetup
 		if ( color != null )
 			this.color.set( color );
 		this.supportsColor = color != null;
-		this.viewer = null;
-		this.listeners = new ArrayList<>();
+		this.listeners = new Listeners.SynchronizedList<>();
+	}
+
+	@Override
+	public Listeners< SetupChangeListener > setupChangeListeners()
+	{
+		return listeners;
 	}
 
 	@Override
@@ -62,15 +86,12 @@ public final class PlaceHolderConverterSetup implements ConverterSetup
 	@Override
 	public void setDisplayRange( final double min, final double max )
 	{
+		if ( this.min == min && this.max == max )
+			return;
+
 		this.min = min;
 		this.max = max;
-		synchronized ( listeners )
-		{
-			for ( final SetupChangeListener l : listeners )
-				l.setupParametersChanged();
-		}
-		if ( viewer != null )
-			viewer.requestRepaint();
+		listeners.list.forEach( l -> l.setupParametersChanged( this ) );
 	}
 
 	@Override
@@ -81,14 +102,11 @@ public final class PlaceHolderConverterSetup implements ConverterSetup
 
 	public void setColor( final int rgb )
 	{
-		this.color.set( rgb );
-		synchronized ( listeners )
-		{
-			for ( final SetupChangeListener l : listeners )
-				l.setupParametersChanged();
-		}
-		if ( viewer != null )
-			viewer.requestRepaint();
+		if ( !supportsColor() || color.get() == rgb )
+			return;
+
+		color.set( rgb );
+		listeners.list.forEach( l -> l.setupParametersChanged( this ) );
 	}
 
 	@Override
@@ -114,48 +132,4 @@ public final class PlaceHolderConverterSetup implements ConverterSetup
 	{
 		return color;
 	}
-
-	@Override
-	public void setViewer( final RequestRepaint viewer )
-	{
-		this.viewer = viewer;
-	}
-
-	/**
-	 * Registers a SetupChangeListener, that will be notified when the display
-	 * range or the color of this {@link ConverterSetup} changes.
-	 *
-	 * @param listener
-	 *            the listener to register.
-	 * @return {@code true} if the listener was successfully registered.
-	 *         {@code false} if it was already registered.
-	 */
-	public boolean addSetupChangeListener( final SetupChangeListener listener )
-	{
-		synchronized( listeners )
-		{
-			if ( !listeners.contains( listener ) )
-			{
-				listeners.add( listener );
-				return true;
-			}
-			return false;
-		}
-	}
-
-	/**
-	 * Removes the specified listener.
-	 *
-	 * @param listener
-	 *            the listener to remove.
-	 * @return {@code true} if the listener was present in the listeners of
-	 *         this model and was successfully removed.
-	 */
-	public boolean removeSetupChangeListener( final SetupChangeListener listener )
-	{
-		synchronized( listeners )
-		{
-			return listeners.remove( listener );
-		}
-	}
 }
diff --git a/src/main/java/bdv/util/Prefs.java b/src/main/java/bdv/util/Prefs.java
index 4b24a64643b56be5479f7cabed4e6ab5ce5c0a01..323321d5e642d97ea8524b539e2769adc4afca74 100644
--- a/src/main/java/bdv/util/Prefs.java
+++ b/src/main/java/bdv/util/Prefs.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/util/PrintSequenceMipmapInfo.java b/src/main/java/bdv/util/PrintSequenceMipmapInfo.java
index 1c842a513a36b0a0da7d38d5eb7ebd2409799be0..68182e348b884e0b50a5d8269eaf5a1e3be57551 100644
--- a/src/main/java/bdv/util/PrintSequenceMipmapInfo.java
+++ b/src/main/java/bdv/util/PrintSequenceMipmapInfo.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/util/RealRandomAccessibleSource.java b/src/main/java/bdv/util/RealRandomAccessibleSource.java
index cc4496fbd68ee5ebfa9ba096def4433fec0edda6..0fef59da4c0c8702baaffcf3d90c46cebce5446d 100644
--- a/src/main/java/bdv/util/RealRandomAccessibleSource.java
+++ b/src/main/java/bdv/util/RealRandomAccessibleSource.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -46,7 +45,7 @@ import net.imglib2.view.Views;
  *
  * @param <T>
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
 public abstract class RealRandomAccessibleSource< T extends Type< T > > implements Source< T >
 {
@@ -58,17 +57,25 @@ public abstract class RealRandomAccessibleSource< T extends Type< T > > implemen
 
 	protected final VoxelDimensions voxelDimensions;
 
+	protected final boolean doBoundingBoxIntersectionCheck;
+
 	public RealRandomAccessibleSource( final RealRandomAccessible< T > accessible, final T type, final String name )
 	{
-		this( accessible, type, name, null );
+		this( accessible, type, name, null, false );
 	}
 
 	public RealRandomAccessibleSource( final RealRandomAccessible< T > accessible, final T type, final String name, final VoxelDimensions voxelDimensions )
+	{
+		this( accessible, type, name, voxelDimensions, false );
+	}
+
+	public RealRandomAccessibleSource( final RealRandomAccessible< T > accessible, final T type, final String name, final VoxelDimensions voxelDimensions, final boolean doBoundingBoxIntersectionCheck )
 	{
 		this.accessible = accessible;
 		this.type = type.createVariable();
 		this.name = name;
 		this.voxelDimensions = voxelDimensions;
+		this.doBoundingBoxIntersectionCheck = doBoundingBoxIntersectionCheck;
 	}
 
 	@Override
@@ -126,4 +133,10 @@ public abstract class RealRandomAccessibleSource< T extends Type< T > > implemen
 	{
 		return 1;
 	}
+
+	@Override
+	public boolean doBoundingBoxCulling()
+	{
+		return doBoundingBoxIntersectionCheck;
+	}
 }
diff --git a/src/main/java/bdv/util/TripleBuffer.java b/src/main/java/bdv/util/TripleBuffer.java
new file mode 100644
index 0000000000000000000000000000000000000000..28cc129b27520d18bf9e7eec2fa6ce10912030f1
--- /dev/null
+++ b/src/main/java/bdv/util/TripleBuffer.java
@@ -0,0 +1,160 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.util;
+
+import java.util.function.Supplier;
+
+/**
+ * Class that provides a triple-buffer-algorithm. For communication between a
+ * painter thread and a display.
+ *
+ * @param <T>
+ *     Type of the buffer.
+ *
+ * @author Matthias Arzt
+ * @author Tobias Pietzsch
+ * @see <a href="https://en.wikipedia.org/wiki/Multiple_buffering">Wikipedia Multiple Buffering</a>
+ */
+public class TripleBuffer< T >
+{
+	private T writable;
+	private T readable;
+	private T exchange;
+
+	private boolean pending = false;
+
+	private final Supplier< T > factory;
+
+	/**
+	 * Creates a triple buffer.
+	 *
+	 * @param factory
+	 * 		Factory method, that is used to create the three buffers.
+	 */
+	public TripleBuffer( Supplier< T > factory )
+	{
+		this.factory = factory;
+	}
+
+	public TripleBuffer()
+	{
+		this( () -> null );
+	}
+
+	/**
+	 * Returns a buffer that can be used in the painter thread for rendering.
+	 * The returned buffer might be a previously {@link #doneWriting submitted}
+	 * buffer that is not used by the display thread anymore.
+	 *
+	 * @return buffer that can be used for rendering, may be {@code null}.
+	 */
+	public synchronized T getWritableBuffer()
+	{
+		if ( writable == null )
+			writable = factory.get();
+		return writable;
+	}
+
+	/**
+	 * This method should be called by the painter thread, to signal that the rendering is completed.
+	 * Assumes that the buffer used for rendering was returned by the last call to {@link #getWritableBuffer()}
+	 */
+	public synchronized void doneWriting()
+	{
+		doneWriting( writable );
+	}
+
+	/**
+	 * This method should be called by the painter thread, to submit a completed
+	 * buffer. This buffer will be supplied to the display thread with the next
+	 * {@link #getReadableBuffer} (unless another {@link #doneWriting} happens
+	 * in between).
+	 */
+	public synchronized void doneWriting( final T value )
+	{
+		writable = exchange;
+		exchange = value;
+		pending = true;
+	}
+
+	/**
+	 * This method should be called in the display thread. To get the latest
+	 * buffer that was completely rendered.
+	 */
+	public synchronized ReadableBuffer< T > getReadableBuffer()
+	{
+		final boolean updated = pending;
+		if ( pending )
+		{
+			final T tmp = exchange;
+			exchange = readable;
+			readable = tmp;
+			pending = false;
+		}
+		return new ReadableBuffer<>( readable, updated );
+	}
+
+	/**
+	 * Returned from {@link #getReadableBuffer}.
+	 * A buffer and a flag indicating whether the buffer is different than the one
+	 * returned from the previous {@link #getReadableBuffer}
+	 */
+	public static class ReadableBuffer< T >
+	{
+		private final T buffer;
+
+		private final boolean updated;
+
+		ReadableBuffer( final T buffer, final boolean updated )
+		{
+			this.buffer = buffer;
+			this.updated = updated;
+		}
+
+		public T getBuffer()
+		{
+			return buffer;
+		}
+
+		public boolean isUpdated()
+		{
+			return updated;
+		}
+	}
+
+	/**
+	 * Free all buffers.
+	 */
+	public synchronized void clear()
+	{
+		writable = null;
+		readable = null;
+		exchange = null;
+	}
+}
diff --git a/src/main/java/bdv/util/WrappedList.java b/src/main/java/bdv/util/WrappedList.java
new file mode 100644
index 0000000000000000000000000000000000000000..01ae7cae297cc9de319c885be81d009a206d3951
--- /dev/null
+++ b/src/main/java/bdv/util/WrappedList.java
@@ -0,0 +1,205 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.util;
+
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.List;
+import java.util.ListIterator;
+
+/**
+ * A {@link List} wrapper that forwards all calls to another {@link List}.
+ *
+ * @author Tobias Pietzsch
+ */
+public class WrappedList< E > implements List< E >
+{
+	private final List< E > list;
+
+	public WrappedList( final List< E > list )
+	{
+		this.list = list;
+	}
+
+	@Override
+	public int size()
+	{
+		return list.size();
+	}
+
+	@Override
+	public boolean isEmpty()
+	{
+		return list.isEmpty();
+	}
+
+	@Override
+	public boolean contains( final Object o )
+	{
+		return list.contains( o );
+	}
+
+	@Override
+	public Iterator< E > iterator()
+	{
+		return list.iterator();
+	}
+
+	@Override
+	public Object[] toArray()
+	{
+		return list.toArray();
+	}
+
+	@Override
+	public < T > T[] toArray( final T[] a )
+	{
+		return list.toArray( a );
+	}
+
+	@Override
+	public boolean add( final E e )
+	{
+		return list.add( e );
+	}
+
+	@Override
+	public boolean remove( final Object o )
+	{
+		return list.remove( o );
+	}
+
+	@Override
+	public boolean containsAll( final Collection< ? > c )
+	{
+		return list.containsAll( c );
+	}
+
+	@Override
+	public boolean addAll( final Collection< ? extends E > c )
+	{
+		return list.addAll( c );
+	}
+
+	@Override
+	public boolean addAll( final int index, final Collection< ? extends E > c )
+	{
+		return list.addAll( index, c );
+	}
+
+	@Override
+	public boolean removeAll( final Collection< ? > c )
+	{
+		return list.removeAll( c );
+	}
+
+	@Override
+	public boolean retainAll( final Collection< ? > c )
+	{
+		return list.retainAll( c );
+	}
+
+	@Override
+	public void clear()
+	{
+		list.clear();
+	}
+
+	@Override
+	public E get( final int index )
+	{
+		return list.get( index );
+	}
+
+	@Override
+	public E set( final int index, final E element )
+	{
+		return list.set( index, element );
+	}
+
+	@Override
+	public void add( final int index, final E element )
+	{
+		list.add( index, element );
+	}
+
+	@Override
+	public E remove( final int index )
+	{
+		return list.remove( index );
+	}
+
+	@Override
+	public int indexOf( final Object o )
+	{
+		return list.indexOf( o );
+	}
+
+	@Override
+	public int lastIndexOf( final Object o )
+	{
+		return list.lastIndexOf( o );
+	}
+
+	@Override
+	public ListIterator< E > listIterator()
+	{
+		return list.listIterator();
+	}
+
+	@Override
+	public ListIterator< E > listIterator( final int index )
+	{
+		return list.listIterator( index );
+	}
+
+	@Override
+	public List< E > subList( final int fromIndex, final int toIndex )
+	{
+		return list.subList( fromIndex, toIndex );
+	}
+
+	@Override
+	public String toString()
+	{
+		return list.toString();
+	}
+
+	@Override
+	public int hashCode()
+	{
+		return list.hashCode();
+	}
+
+	@Override
+	public boolean equals( final Object obj )
+	{
+		return list.equals( obj );
+	}
+}
diff --git a/src/main/java/bdv/viewer/BasicViewerState.java b/src/main/java/bdv/viewer/BasicViewerState.java
new file mode 100644
index 0000000000000000000000000000000000000000..9fdf6efd7ff20b7a16eacf32a92aa5da472fe411
--- /dev/null
+++ b/src/main/java/bdv/viewer/BasicViewerState.java
@@ -0,0 +1,1663 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.viewer;
+
+import bdv.util.Affine3DHelpers;
+import bdv.util.WrappedList;
+import gnu.trove.map.TObjectIntMap;
+import gnu.trove.map.hash.TObjectIntHashMap;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import net.imglib2.realtransform.AffineTransform3D;
+import org.scijava.listeners.Listeners;
+
+import static bdv.viewer.ViewerStateChange.CURRENT_GROUP_CHANGED;
+import static bdv.viewer.ViewerStateChange.CURRENT_SOURCE_CHANGED;
+import static bdv.viewer.ViewerStateChange.CURRENT_TIMEPOINT_CHANGED;
+import static bdv.viewer.ViewerStateChange.DISPLAY_MODE_CHANGED;
+import static bdv.viewer.ViewerStateChange.GROUP_ACTIVITY_CHANGED;
+import static bdv.viewer.ViewerStateChange.GROUP_NAME_CHANGED;
+import static bdv.viewer.ViewerStateChange.INTERPOLATION_CHANGED;
+import static bdv.viewer.ViewerStateChange.NUM_GROUPS_CHANGED;
+import static bdv.viewer.ViewerStateChange.NUM_SOURCES_CHANGED;
+import static bdv.viewer.ViewerStateChange.NUM_TIMEPOINTS_CHANGED;
+import static bdv.viewer.ViewerStateChange.SOURCE_ACTIVITY_CHANGED;
+import static bdv.viewer.ViewerStateChange.SOURCE_TO_GROUP_ASSIGNMENT_CHANGED;
+import static bdv.viewer.ViewerStateChange.VIEWER_TRANSFORM_CHANGED;
+import static bdv.viewer.ViewerStateChange.VISIBILITY_CHANGED;
+import static gnu.trove.impl.Constants.DEFAULT_CAPACITY;
+import static gnu.trove.impl.Constants.DEFAULT_LOAD_FACTOR;
+
+/**
+ * Maintains the BigDataViewer state and implements {@link ViewerState} to
+ * expose query and modification methods. {@code ViewerStateChangeListener}s can
+ * be registered and will be notified about various {@link ViewerStateChange
+ * state changes}.
+ * <p>
+ * <em>This class is not thread-safe.</em>
+ * </p>
+ *
+ * @author Tobias Pietzsch
+ */
+public class BasicViewerState implements ViewerState
+{
+	private final Listeners.List< ViewerStateChangeListener > listeners;
+
+	/**
+	 * The current number of available timepoints.
+	 */
+	private int numTimepoints;
+
+	/**
+	 * Which timepoint (index) is currently shown.
+	 */
+	private int currentTimepoint;
+
+	/**
+	 * Transforms global coordinates to viewer coordinates.
+	 */
+	private final AffineTransform3D viewerTransform;
+
+	/**
+	 * The current interpolation method.
+	 */
+	private Interpolation interpolation;
+
+	/**
+	 * The current display mode.
+	 */
+	private DisplayMode displayMode;
+
+	// -- sources --
+
+	private final List< SourceAndConverter< ? > > sources;
+
+	private final List< SourceAndConverter< ? > > unmodifiableSources;
+
+	private final Set< SourceAndConverter< ? > > activeSources;
+
+	private final Set< SourceAndConverter< ? > > unmodifiableActiveSources;
+
+	private SourceAndConverter< ? > currentSource;
+
+	private final TObjectIntMap< SourceAndConverter< ? > > sourceIndices;
+
+	private final Set< SourceAndConverter< ? > > previousVisibleSources;
+
+	// -- groups --
+
+	private final List< SourceGroup > groups;
+
+	private final List< SourceGroup > unmodifiableGroups;
+
+	private final Map< SourceGroup, GroupData > groupData;
+
+	private final Set< SourceGroup > activeGroups;
+
+	private final Set< SourceGroup > unmodifiableActiveGroups;
+
+	private SourceGroup currentGroup;
+
+	private final TObjectIntMap< SourceGroup > groupIndices;
+
+	private static final int NO_ENTRY_VALUE = -1;
+
+	/**
+	 * Create an empty state without any sources or groups. Interpolation is
+	 * initialized as {@code Interpolation.NEARESTNEIGHBOR}. Display mode is
+	 * initialized as {@code DisplayMode.SINGLE}.
+	 */
+	public BasicViewerState()
+	{
+		listeners = new Listeners.List<>();
+		numTimepoints = 0;
+		currentTimepoint = 0;
+		viewerTransform = new AffineTransform3D();
+		interpolation = Interpolation.NEARESTNEIGHBOR;
+		displayMode = DisplayMode.SINGLE;
+		sources = new ArrayList<>();
+		unmodifiableSources = new UnmodifiableSources();
+		activeSources = new HashSet<>();
+		unmodifiableActiveSources = Collections.unmodifiableSet( activeSources );
+		sourceIndices = new TObjectIntHashMap<>( DEFAULT_CAPACITY, DEFAULT_LOAD_FACTOR, NO_ENTRY_VALUE );
+		previousVisibleSources = new HashSet<>();
+		groups = new ArrayList<>();
+		unmodifiableGroups = new UnmodifiableGroups();
+		groupData = new HashMap<>();
+		activeGroups = new HashSet<>();
+		unmodifiableActiveGroups = Collections.unmodifiableSet( activeGroups );
+		groupIndices = new TObjectIntHashMap<>( DEFAULT_CAPACITY, DEFAULT_LOAD_FACTOR, NO_ENTRY_VALUE );
+	}
+
+	/**
+	 * Create a copy of the given {@code ViewerState} (except for (@link
+	 * #changeListeners()}, which are not copied).
+	 */
+	public BasicViewerState( final ViewerState other )
+	{
+		listeners = new Listeners.List<>();
+
+		numTimepoints = other.getNumTimepoints();
+		currentTimepoint = other.getCurrentTimepoint();
+		viewerTransform = other.getViewerTransform();
+		interpolation = other.getInterpolation();
+		displayMode = other.getDisplayMode();
+
+		sources = new ArrayList<>( other.getSources() );
+		unmodifiableSources = new UnmodifiableSources();
+		activeSources = new HashSet<>( other.getActiveSources() );
+		unmodifiableActiveSources = Collections.unmodifiableSet( activeSources );
+		currentSource = other.getCurrentSource();
+		sourceIndices = new TObjectIntHashMap<>( DEFAULT_CAPACITY, DEFAULT_LOAD_FACTOR, NO_ENTRY_VALUE );
+		for ( int i = 0; i < sources.size(); ++i )
+			sourceIndices.put( sources.get( i ), i );
+		previousVisibleSources = new HashSet<>( other.getVisibleSources() );
+
+		groups = new ArrayList<>( other.getGroups() );
+		unmodifiableGroups = new UnmodifiableGroups();
+		groupData = new HashMap<>();
+		other.getGroups().forEach( group -> {
+			final GroupData data = new GroupData();
+			data.name = other.getGroupName( group );
+			data.sources.addAll( other.getSourcesInGroup( group ) );
+			groupData.put( group, data );
+		} );
+		activeGroups = new HashSet<>( other.getActiveGroups() );
+		unmodifiableActiveGroups = Collections.unmodifiableSet( activeGroups );
+		currentGroup = other.getCurrentGroup();
+		groupIndices = new TObjectIntHashMap<>( DEFAULT_CAPACITY, DEFAULT_LOAD_FACTOR, NO_ENTRY_VALUE );
+		for ( int i = 0; i < groups.size(); ++i )
+			groupIndices.put( groups.get( i ), i );
+	}
+
+	/**
+	 * Set this {@code ViewerState} to {@code other}.
+	 * <em>No {@code ViewerStateChange} events are fired.</em>
+	 */
+	public void set( final ViewerState other )
+	{
+		numTimepoints = other.getNumTimepoints();
+		currentTimepoint = other.getCurrentTimepoint();
+		viewerTransform.set( other.getViewerTransform() );
+		interpolation = other.getInterpolation();
+		displayMode = other.getDisplayMode();
+
+		sources.clear();
+		sources.addAll( other.getSources() );
+		activeSources.clear();
+		activeSources.addAll( other.getActiveSources() );
+		currentSource = other.getCurrentSource();
+		sourceIndices.clear();
+		for ( int i = 0; i < sources.size(); ++i )
+			sourceIndices.put( sources.get( i ), i );
+		previousVisibleSources.clear();
+		previousVisibleSources.addAll( other.getVisibleSources() );
+
+		groups.clear();
+		groups.addAll( other.getGroups() );
+		groupData.clear();
+		other.getGroups().forEach( group -> {
+			final GroupData data = new GroupData();
+			data.name = other.getGroupName( group );
+			data.sources.addAll( other.getSourcesInGroup( group ) );
+			groupData.put( group, data );
+		} );
+		activeGroups.clear();
+		activeGroups.addAll( other.getActiveGroups() );
+		currentGroup = other.getCurrentGroup();
+		groupIndices.clear();
+		for ( int i = 0; i < groups.size(); ++i )
+			groupIndices.put( groups.get( i ), i );
+	}
+
+	/**
+	 * {@code ViewerStateChangeListener}s can be added/removed here.
+	 */
+	@Override
+	public Listeners< ViewerStateChangeListener > changeListeners()
+	{
+		return listeners;
+	}
+
+	/**
+	 * Get a snapshot of this ViewerState.
+	 *
+	 * @return unmodifiable copy of the current state
+	 */
+	@Override
+	public ViewerState snapshot()
+	{
+		return new UnmodifiableViewerState( new BasicViewerState( this ) );
+	}
+
+	@Override
+	public Interpolation getInterpolation()
+	{
+		return interpolation;
+	}
+
+	@Override
+	public void setInterpolation( final Interpolation i )
+	{
+		if ( interpolation != i )
+		{
+			interpolation = i;
+			notifyListeners( INTERPOLATION_CHANGED );
+		}
+	}
+
+	@Override
+	public DisplayMode getDisplayMode()
+	{
+		return displayMode;
+	}
+
+	@Override
+	public void setDisplayMode( final DisplayMode mode )
+	{
+		if ( displayMode != mode )
+		{
+			displayMode = mode;
+			notifyListeners( DISPLAY_MODE_CHANGED );
+			checkVisibilityChanged();
+		}
+	}
+
+	@Override
+	public int getNumTimepoints()
+	{
+		return numTimepoints;
+	}
+
+	@Override
+	public void setNumTimepoints( final int n )
+	{
+		if ( n < 1 )
+			throw new IllegalArgumentException("numTimepoints must be >= 1");
+
+		if ( numTimepoints != n )
+		{
+			numTimepoints = n;
+			notifyListeners( NUM_TIMEPOINTS_CHANGED );
+
+			if ( currentTimepoint > n - 1 )
+				setCurrentTimepoint( n - 1 );
+		}
+	}
+
+	@Override
+	public int getCurrentTimepoint()
+	{
+		return currentTimepoint;
+	}
+
+	@Override
+	public void setCurrentTimepoint( final int t )
+	{
+		if ( t >= numTimepoints || t < 0 )
+			throw new IllegalArgumentException( "currentTimepoint must be < numTimepoints and >= 0" );
+
+		if ( currentTimepoint != t )
+		{
+			currentTimepoint = t;
+			notifyListeners( CURRENT_TIMEPOINT_CHANGED );
+		}
+	}
+
+	@Override
+	public void getViewerTransform( final AffineTransform3D t )
+	{
+		t.set( viewerTransform );
+	}
+
+	@Override
+	public void setViewerTransform( final AffineTransform3D t )
+	{
+		if ( !Affine3DHelpers.equals( viewerTransform, t ) )
+		{
+			viewerTransform.set( t );
+			notifyListeners( VIEWER_TRANSFORM_CHANGED );
+		}
+	}
+
+	// --------------------
+	// --    sources     --
+	// --------------------
+
+	/**
+	 * Get the list of sources. The returned {@code List} reflects changes to
+	 * the viewer state. It is unmodifiable and not thread-safe.
+	 *
+	 * @return the list of sources
+	 */
+	@Override
+	public List< SourceAndConverter< ? > > getSources()
+	{
+		return unmodifiableSources;
+	}
+
+	/**
+	 * Get the current source. (May return {@code null} if there is no current
+	 * source)
+	 *
+	 * @return the current source
+	 */
+	@Override
+	public SourceAndConverter< ? > getCurrentSource()
+	{
+		return currentSource;
+	}
+
+	/**
+	 * Returns {@code true} if {@code source} is the current source. Equivalent
+	 * to {@code (getCurrentSource() == source)}.
+	 *
+	 * @param source
+	 *     the source. Passing {@code null} checks whether no source is current.
+	 * @return {@code true} if {@code source} is the current source
+	 */
+	@Override
+	public boolean isCurrentSource( final SourceAndConverter< ? > source )
+	{
+		return Objects.equals( source, currentSource );
+	}
+
+	/**
+	 * Make {@code source} the current source. Returns {@code true}, if current
+	 * source changes as a result of the call. Returns {@code false}, if
+	 * {@code source} is already the current source.
+	 *
+	 * @param source
+	 *     the source to make current. Passing {@code null} clears the current
+	 *     source.
+	 *
+	 * @return {@code true}, if current source changed as a result of the call
+	 *
+	 * @throws IllegalArgumentException
+	 *     if {@code source} is not contained in the state (and not
+	 *     {@code null}).
+	 */
+	@Override
+	public boolean setCurrentSource( final SourceAndConverter< ? > source )
+	{
+		checkSourcePresentAllowNull( source );
+
+		final boolean modified = !Objects.equals( currentSource, source );
+		currentSource = source;
+		if ( modified )
+		{
+			notifyListeners( CURRENT_SOURCE_CHANGED );
+			checkVisibilityChanged();
+		}
+		return modified;
+	}
+
+	/**
+	 * Get the set of active sources. The returned {@code Set} reflects changes
+	 * to the viewer state. It is unmodifiable and not thread-safe.
+	 *
+	 * @return the set of active sources
+	 */
+	@Override
+	public Set< SourceAndConverter< ? > > getActiveSources()
+	{
+		return unmodifiableActiveSources;
+	}
+
+	/**
+	 * Check whether the given {@code source} is active.
+	 *
+	 * @return {@code true}, if {@code source} is active
+	 *
+	 * @throws NullPointerException
+	 *     if {@code source == null}
+	 * @throws IllegalArgumentException
+	 *     if {@code source} is not contained in the state (and not
+	 *     {@code null}).
+	 */
+	@Override
+	public boolean isSourceActive( final SourceAndConverter< ? > source )
+	{
+		checkSourcePresent( source );
+
+		return activeSources.contains( source );
+	}
+
+	/**
+	 * Set {@code source} active or inactive.
+	 * <p>
+	 * Returns {@code true}, if source activity changes as a result of the call.
+	 * Returns {@code false}, if {@code source} is already in the desired
+	 * {@code active} state.
+	 *
+	 * @return {@code true}, if source activity changed as a result of the call
+	 *
+	 * @throws NullPointerException
+	 *     if {@code source == null}
+	 * @throws IllegalArgumentException
+	 *     if {@code source} is not contained in the state (and not
+	 *     {@code null}).
+	 */
+	@Override
+	public boolean setSourceActive( final SourceAndConverter< ? > source, final boolean active )
+	{
+		checkSourcePresent( source );
+
+		final boolean modified = active ? activeSources.add( source ) : activeSources.remove( source );
+		if ( modified )
+		{
+			notifyListeners( SOURCE_ACTIVITY_CHANGED );
+			checkVisibilityChanged();
+		}
+		return modified;
+	}
+
+	/**
+	 * Set all sources in {@code collection} active or inactive.
+	 * <p>
+	 * Returns {@code true}, if source activity changes as a result of the call.
+	 * Returns {@code false}, if all sources were already in the desired
+	 * {@code active} state.
+	 *
+	 * @return {@code true}, if source activity changed as a result of the call
+	 *
+	 * @throws NullPointerException
+	 *     if {@code collection == null} or any element of {@code collection} is
+	 *     {@code null}.
+	 * @throws IllegalArgumentException
+	 *     if any element of {@code collection} is not contained in the state.
+	 */
+	@Override
+	public boolean setSourcesActive( final Collection< ? extends SourceAndConverter< ? > > collection, final boolean active )
+	{
+		checkSourcesPresent( collection );
+
+		final boolean modified = active ? activeSources.addAll( collection ) : activeSources.removeAll( collection );
+		if ( modified )
+		{
+			notifyListeners( SOURCE_ACTIVITY_CHANGED );
+			checkVisibilityChanged();
+		}
+		return modified;
+	}
+
+	/**
+	 * Check whether the given {@code source} is visible.
+	 * <p>
+	 * Whether a source is visible depends on the {@link #getDisplayMode()
+	 * display mode}:
+	 * <ul>
+	 * <li>In {@code DisplayMode.SINGLE} only the current source is visible.</li>
+	 * <li>In {@code DisplayMode.GROUP} the sources in the current group are visible.</li>
+	 * <li>In {@code DisplayMode.FUSED} all active sources are visible.</li>
+	 * <li>In {@code DisplayMode.FUSEDROUP} the sources in all active groups are visible.</li>
+	 * </ul>
+	 *
+	 * @return {@code true}, if {@code source} is visible
+	 *
+	 * @throws NullPointerException
+	 *     if {@code source == null}
+	 * @throws IllegalArgumentException
+	 *     if {@code source} is not contained in the state (and not
+	 *     {@code null}).
+	 */
+	@Override
+	public boolean isSourceVisible( final SourceAndConverter< ? > source )
+	{
+		checkSourcePresent( source );
+
+		switch ( displayMode )
+		{
+		case SINGLE:
+		default:
+			return Objects.equals( currentSource, source );
+		case GROUP:
+			return currentGroup != null && groupData.get( currentGroup ).sources.contains( source );
+		case FUSED:
+			return isSourceActive( source );
+		case FUSEDGROUP:
+			for ( final SourceGroup group : activeGroups )
+				if ( groupData.get( group ).sources.contains( source ) )
+					return true;
+			return false;
+		}
+	}
+
+	/**
+	 * Check whether the given {@code source} is both visible and provides image
+	 * data for the current timepoint.
+	 * <p>
+	 * Whether a source is visible depends on the {@link #getDisplayMode()
+	 * display mode}:
+	 * <ul>
+	 * <li>In {@code DisplayMode.SINGLE} only the current source is visible.</li>
+	 * <li>In {@code DisplayMode.GROUP} the sources in the current group are visible.</li>
+	 * <li>In {@code DisplayMode.FUSED} all active sources are visible.</li>
+	 * <li>In {@code DisplayMode.FUSEDROUP} the sources in all active groups are visible.</li>
+	 * </ul>
+	 * Additionally, the source must be {@link bdv.viewer.Source#isPresent(int)
+	 * present}, i.e., provide image data for the {@link #getCurrentTimepoint()
+	 * current timepoint}.
+	 *
+	 * @return {@code true}, if {@code source} is both visible and present
+	 *
+	 * @throws NullPointerException
+	 *     if {@code source == null}
+	 * @throws IllegalArgumentException
+	 *     if {@code source} is not contained in the state (and not
+	 *     {@code null}).
+	 */
+	@Override
+	public boolean isSourceVisibleAndPresent( final SourceAndConverter< ? > source )
+	{
+		return isSourceVisible( source ) && source.getSpimSource().isPresent( currentTimepoint );
+	}
+
+	/**
+	 * Get the set of visible sources.
+	 * <p>
+	 * The returned {@code Set} is a copy. Changes to the set will not be
+	 * reflected in the viewer state, and vice versa.
+	 * <p>
+	 * Whether a source is visible depends on the {@link #getDisplayMode()
+	 * display mode}:
+	 * <ul>
+	 * <li>In {@code DisplayMode.SINGLE} only the current source is visible.</li>
+	 * <li>In {@code DisplayMode.GROUP} the sources in the current group are visible.</li>
+	 * <li>In {@code DisplayMode.FUSED} all active sources are visible.</li>
+	 * <li>In {@code DisplayMode.FUSEDROUP} the sources in all active groups are visible.</li>
+	 * </ul>
+	 *
+	 * @return the set of visible sources
+	 */
+	@Override
+	public Set< SourceAndConverter< ? > > getVisibleSources()
+	{
+		final Set< SourceAndConverter< ? > > visible = new HashSet<>();
+		switch ( displayMode )
+		{
+		case SINGLE:
+			if ( currentSource != null )
+				visible.add( currentSource );
+			break;
+		case GROUP:
+			if ( currentGroup != null )
+				visible.addAll( groupData.get( currentGroup ).sources );
+			break;
+		case FUSED:
+			visible.addAll( activeSources );
+			break;
+		case FUSEDGROUP:
+			for ( final SourceGroup group : activeGroups )
+				visible.addAll( groupData.get( group ).sources );
+			break;
+		}
+		return visible;
+	}
+
+	/**
+	 * Get the set of visible sources that also provide image data for the
+	 * current timepoint.
+	 * <p>
+	 * The returned {@code Set} is a copy. Changes to the set will not be
+	 * reflected in the viewer state, and vice versa.
+	 * <p>
+	 * Whether a source is visible depends on the {@link #getDisplayMode()
+	 * display mode}:
+	 * <ul>
+	 * <li>In {@code DisplayMode.SINGLE} only the current source is visible.</li>
+	 * <li>In {@code DisplayMode.GROUP} the sources in the current group are visible.</li>
+	 * <li>In {@code DisplayMode.FUSED} all active sources are visible.</li>
+	 * <li>In {@code DisplayMode.FUSEDROUP} the sources in all active groups are visible.</li>
+	 * </ul>
+	 * Additionally, the source must be {@link bdv.viewer.Source#isPresent(int)
+	 * present}, i.e., provide image data for the {@link #getCurrentTimepoint()
+	 * current timepoint}.
+	 *
+	 * @return the set of sources that are both visible and present
+	 */
+	@Override
+	public Set< SourceAndConverter< ? > > getVisibleAndPresentSources()
+	{
+		final Set< SourceAndConverter< ? > > visible = getVisibleSources();
+		visible.removeIf( source -> !source.getSpimSource().isPresent( currentTimepoint ) );
+		return visible;
+	}
+
+	/**
+	 * Check whether the state contains the {@code source}.
+	 *
+	 * @return {@code true}, if {@code source} is in the list of sources.
+	 *
+	 * @throws NullPointerException
+	 *     if {@code source == null}
+	 */
+	@Override
+	public boolean containsSource( final SourceAndConverter< ? > source )
+	{
+		if ( source == null )
+			throw new NullPointerException();
+
+		return sourceIndices.containsKey( source );
+	}
+
+	/**
+	 * Add {@code source} to the state. Returns {@code true}, if the source is
+	 * added. Returns {@code false}, if the source is already present.
+	 * <p>
+	 * If {@code source} is added and no other source was current, then
+	 * {@code source} is made current
+	 *
+	 * @return {@code true}, if list of sources changed as a result of the call.
+	 *
+	 * @throws NullPointerException
+	 *     if {@code source == null}
+	 */
+	@Override
+	public boolean addSource( final SourceAndConverter< ? > source )
+	{
+		if ( source == null )
+			throw new NullPointerException();
+
+		final boolean modified = !sourceIndices.containsKey( source );
+		if ( modified )
+		{
+			final int nextIndex = sources.size();
+			sources.add( source );
+			sourceIndices.put( source, nextIndex );
+			final boolean currentSourceChanged = ( currentSource == null );
+			if ( currentSourceChanged )
+				currentSource = source;
+
+			notifyListeners( NUM_SOURCES_CHANGED );
+			if ( currentSourceChanged )
+				notifyListeners( CURRENT_SOURCE_CHANGED );
+			checkVisibilityChanged();
+		}
+		return modified;
+	}
+
+	/**
+	 * Add all sources in {@code collection} to the state. Returns {@code true},
+	 * if at least one source was added. Returns {@code false}, if all sources
+	 * were already present.
+	 * <p>
+	 * If any sources are added and no other source was current, then the first
+	 * added sources will be made current.
+	 *
+	 * @return {@code true}, if list of sources changed as a result of the call.
+	 *
+	 * @throws NullPointerException
+	 *     if {@code collection == null} or any element of {@code collection} is
+	 *     {@code null}.
+	 */
+	@Override
+	public boolean addSources( final Collection< ? extends SourceAndConverter< ? > > collection )
+	{
+		checkAllNonNull( collection );
+
+		boolean modified = false;
+		boolean currentSourceChanged = false;
+		for ( final SourceAndConverter< ? > source : collection )
+		{
+			if ( sourceIndices.containsKey( source ) )
+				continue;
+
+			modified = true;
+			final int nextIndex = sources.size();
+			sources.add( source );
+			sourceIndices.put( source, nextIndex );
+			if ( currentSource == null )
+			{
+				currentSourceChanged = true;
+				currentSource = source;
+			}
+		}
+		if ( modified )
+		{
+			notifyListeners( NUM_SOURCES_CHANGED );
+			if ( currentSourceChanged )
+				notifyListeners( CURRENT_SOURCE_CHANGED );
+			checkVisibilityChanged();
+		}
+		return modified;
+	}
+
+	/**
+	 * Remove {@code source} from the state.
+	 * <p>
+	 * Returns {@code true}, if {@code source} was removed from the state.
+	 * Returns {@code false}, if {@code source} was not contained in state.
+	 * <p>
+	 * The {@code source} is also removed from any groups that contained it. If
+	 * {@code source} was current, then the first source in the list of sources
+	 * is made current (if it exists).
+	 *
+	 * @return {@code true}, if list of sources changed as a result of the call
+	 *
+	 * @throws NullPointerException
+	 *     if {@code source == null}
+	 */
+	@Override
+	public boolean removeSource( final SourceAndConverter< ? > source )
+	{
+		if ( source == null )
+			throw new NullPointerException();
+
+		final int removedIndex = sourceIndices.remove( source );
+		final boolean modified = ( removedIndex != NO_ENTRY_VALUE );
+		if ( modified )
+		{
+			sources.remove( removedIndex );
+			for ( int i = removedIndex; i < sources.size(); ++i )
+				sourceIndices.put( sources.get( i ), i );
+
+			activeSources.remove( source );
+			final boolean currentSourceChanged = source.equals( currentSource );
+			if ( currentSourceChanged )
+				currentSource = sources.isEmpty() ? null : sources.get( 0 );
+
+			boolean sourceToGroupAssignmentChanged = false;
+			for ( final GroupData groupData : groupData.values() )
+				sourceToGroupAssignmentChanged |= groupData.sources.remove( source );
+
+			notifyListeners( NUM_SOURCES_CHANGED );
+			if ( currentSourceChanged )
+				notifyListeners( CURRENT_SOURCE_CHANGED );
+			if ( sourceToGroupAssignmentChanged )
+				notifyListeners( SOURCE_TO_GROUP_ASSIGNMENT_CHANGED );
+			checkVisibilityChanged();
+		}
+		return modified;
+	}
+
+	/**
+	 * Remove all sources in {@code collection} from the state. Returns
+	 * {@code true}, if at least one source was removed. Returns {@code false},
+	 * if none of the sources was present.
+	 * <p>
+	 * Removed sources are also removed from any groups containing them. If the
+	 * current source was removed, then the first source in the remaining list
+	 * of sources is made current (if it exists).
+	 *
+	 * @return {@code true}, if list of sources changed as a result of the call.
+	 *
+	 * @throws NullPointerException
+	 *     if {@code collection == null} or any element of {@code collection} is
+	 *     {@code null}.
+	 */
+	@Override
+	public boolean removeSources( final Collection< ? extends SourceAndConverter< ? > > collection )
+	{
+		checkAllNonNull( collection );
+
+		final boolean modified = sources.removeAll( collection );
+		final boolean currentSourceChanged = collection.contains( currentSource );
+
+		if ( modified )
+		{
+			sourceIndices.clear();
+			for ( int i = 0; i < sources.size(); ++i )
+				sourceIndices.put( sources.get( i ), i );
+			activeSources.removeAll( collection );
+
+			boolean sourceToGroupAssignmentChanged = false;
+			for ( final GroupData groupData : groupData.values() )
+				sourceToGroupAssignmentChanged |= groupData.sources.removeAll( sources );
+
+			if ( currentSourceChanged )
+				currentSource = sources.isEmpty() ? null : sources.get( 0 );
+
+			notifyListeners( NUM_SOURCES_CHANGED );
+			if ( currentSourceChanged )
+				notifyListeners( CURRENT_SOURCE_CHANGED );
+			if ( sourceToGroupAssignmentChanged )
+				notifyListeners( SOURCE_TO_GROUP_ASSIGNMENT_CHANGED );
+			checkVisibilityChanged();
+		}
+		return modified;
+
+	}
+
+	// TODO addSource with index
+	// TODO addSources with index
+
+	/**
+	 * Remove all sources from the state.
+	 */
+	@Override
+	public void clearSources()
+	{
+		if ( sources.isEmpty() )
+			return;
+
+		sources.clear();
+		sourceIndices.clear();
+		activeSources.clear();
+
+		boolean sourceToGroupAssignmentChanged = false;
+		for ( final GroupData groupData : groupData.values() )
+		{
+			sourceToGroupAssignmentChanged |= !groupData.sources.isEmpty();
+			groupData.sources.clear();
+		}
+
+		final boolean currentSourceChanged = ( currentSource != null );
+		currentSource = null;
+
+		notifyListeners( NUM_SOURCES_CHANGED );
+		if ( currentSourceChanged )
+			notifyListeners( CURRENT_SOURCE_CHANGED );
+		if ( sourceToGroupAssignmentChanged )
+			notifyListeners( SOURCE_TO_GROUP_ASSIGNMENT_CHANGED );
+		checkVisibilityChanged();
+	}
+
+	/**
+	 * Returns a {@link Comparator} that compares sources according to the order
+	 * in which they occur in the sources list. (Sources that do not occur in
+	 * the list are ordered before any source in the list).
+	 */
+	@Override
+	public Comparator< SourceAndConverter< ? > > sourceOrder()
+	{
+		return Comparator.comparingInt( sourceIndices::get );
+	}
+
+	// --------------------
+	// --     groups     --
+	// --------------------
+
+	/**
+	 * Get the list of groups. The returned {@code List} reflects changes to the
+	 * viewer state. It is unmodifiable and not thread-safe.
+	 *
+	 * @return the list of groups
+	 */
+	@Override
+	public List< SourceGroup > getGroups()
+	{
+		return unmodifiableGroups;
+	}
+
+	/**
+	 * Get the current group. (May return {@code null} if there is no current
+	 * group)
+	 *
+	 * @return the current group
+	 */
+	@Override
+	public SourceGroup getCurrentGroup()
+	{
+		return currentGroup;
+	}
+
+	/**
+	 * Returns {@code true} if {@code group} is the current group. Equivalent to
+	 * {@code (getCurrentGroup() == group)}.
+	 *
+	 * @return {@code true} if {@code group} is the current group
+	 */
+	@Override
+	public boolean isCurrentGroup( final SourceGroup group )
+	{
+		return Objects.equals( group, currentGroup );
+	}
+
+	/**
+	 * Make {@code group} the current group. Returns {@code true}, if current
+	 * group changes as a result of the call. Returns {@code false}, if
+	 * {@code group} is already the current group.
+	 *
+	 * @param group
+	 *     the group to make current
+	 * @return {@code true}, if current group changed as a result of the call.
+	 *
+	 * @throws IllegalArgumentException
+	 *     if {@code group} is not contained in the state (and not
+	 *     {@code null}).
+	 */
+	@Override
+	public boolean setCurrentGroup( final SourceGroup group )
+	{
+		checkGroupPresentAllowNull( group );
+
+		final boolean modified = !Objects.equals( currentGroup, group );
+		currentGroup = group;
+		if ( modified )
+		{
+			notifyListeners( CURRENT_GROUP_CHANGED );
+			checkVisibilityChanged();
+		}
+		return modified;
+	}
+
+	/**
+	 * Get the set of active groups. The returned {@code Set} reflects changes
+	 * to the viewer state. It is unmodifiable and not thread-safe.
+	 *
+	 * @return the set of active groups
+	 */
+	@Override
+	public Set< SourceGroup > getActiveGroups()
+	{
+		return unmodifiableActiveGroups;
+	}
+
+	/**
+	 * Check whether the given {@code group} is active.
+	 *
+	 * @return {@code true}, if {@code group} is active
+	 *
+	 * @throws NullPointerException
+	 *     if {@code group == null}
+	 * @throws IllegalArgumentException
+	 *     if {@code group} is not contained in the state (and not
+	 *     {@code null}).
+	 */
+	@Override
+	public boolean isGroupActive( final SourceGroup group )
+	{
+		checkGroupPresent( group );
+
+		return activeGroups.contains( group );
+	}
+
+	/**
+	 * Set {@code group} active or inactive.
+	 * <p>
+	 * Returns {@code true}, if group activity changes as a result of the call.
+	 * Returns {@code false}, if {@code group} is already in the desired
+	 * {@code active} state.
+	 *
+	 * @return {@code true}, if group activity changed as a result of the call
+	 *
+	 * @throws NullPointerException
+	 *     if {@code group == null}
+	 * @throws IllegalArgumentException
+	 *     if {@code group} is not contained in the state (and not
+	 *     {@code null}).
+	 */
+	@Override
+	public boolean setGroupActive( final SourceGroup group, final boolean active )
+	{
+		checkGroupPresent( group );
+
+		final boolean modified = active ? activeGroups.add( group ) : activeGroups.remove( group );
+		if ( modified )
+		{
+			notifyListeners( GROUP_ACTIVITY_CHANGED );
+			checkVisibilityChanged();
+		}
+		return modified;
+	}
+
+	/**
+	 * Set all groups in {@code collection} active or inactive.
+	 * <p>
+	 * Returns {@code true}, if group activity changes as a result of the call.
+	 * Returns {@code false}, if all groups were already in the desired
+	 * {@code active} state.
+	 *
+	 * @return {@code true}, if group activity changed as a result of the call
+	 *
+	 * @throws NullPointerException
+	 *     if {@code collection == null} or any element of {@code collection} is
+	 *     {@code null}.
+	 * @throws IllegalArgumentException
+	 *     if any element of {@code collection} is not contained in the state.
+	 */
+	@Override
+	public boolean setGroupsActive( final Collection< ? extends SourceGroup > collection, final boolean active )
+	{
+		checkGroupsPresent( collection );
+
+		final boolean modified = active ? activeGroups.addAll( collection ) : activeGroups.removeAll( collection );
+		if ( modified )
+		{
+			notifyListeners( GROUP_ACTIVITY_CHANGED );
+			checkVisibilityChanged();
+		}
+		return modified;
+	}
+
+	/**
+	 * Get the name of a {@code group}.
+	 *
+	 * @return name of the group, may be {@code null}
+	 *
+	 * @throws NullPointerException
+	 *     if {@code group == null}
+	 * @throws IllegalArgumentException
+	 *     if {@code group} is not contained in the state (and not
+	 *     {@code null}).
+	 */
+	@Override
+	public String getGroupName( final SourceGroup group )
+	{
+		checkGroupPresent( group );
+
+		return groupData.get( group ).name;
+	}
+
+	/**
+	 * Set the {@code name} of a {@code group}.
+	 *
+	 * @throws NullPointerException
+	 *     if {@code group == null}
+	 * @throws IllegalArgumentException
+	 *     if {@code group} is not contained in the state (and not
+	 *     {@code null}).
+	 */
+	@Override
+	public void setGroupName( final SourceGroup group, final String name )
+	{
+		checkGroupPresent( group );
+
+		final GroupData data = groupData.get( group );
+		if ( !Objects.equals( data.name, name ) )
+		{
+			data.name = name;
+			notifyListeners( GROUP_NAME_CHANGED );
+		}
+	}
+
+	/**
+	 * Check whether the state contains the {@code group}.
+	 *
+	 * @return {@code true}, if {@code group} is in the list of groups.
+	 *
+	 * @throws NullPointerException
+	 *     if {@code group == null}
+	 */
+	@Override
+	public boolean containsGroup( final SourceGroup group )
+	{
+		if ( group == null )
+			throw new NullPointerException();
+
+		return groupIndices.containsKey( group );
+	}
+
+	/**
+	 * Add {@code group} to the state. Returns {@code true}, if the group is
+	 * added. Returns {@code false}, if the group is already present.
+	 * <p>
+	 * If {@code group} is added and no other group was current, then
+	 * {@code group} is made current
+	 *
+	 * @return {@code true}, if list of groups changed as a result of the call.
+	 *
+	 * @throws NullPointerException
+	 *     if {@code group == null}
+	 */
+	@Override
+	public boolean addGroup( final SourceGroup group )
+	{
+		if ( group == null )
+			throw new NullPointerException();
+
+		final boolean modified = !groupIndices.containsKey( group );
+		if ( modified )
+		{
+			final int nextIndex = groups.size();
+			groups.add( group );
+			groupData.put( group, new GroupData() );
+			groupIndices.put( group, nextIndex );
+			final boolean currentGroupChanged = ( currentGroup == null );
+			if ( currentGroupChanged )
+				currentGroup = group;
+
+			notifyListeners( NUM_GROUPS_CHANGED );
+			if ( currentGroupChanged )
+				notifyListeners( CURRENT_GROUP_CHANGED );
+			// new group is empty, so visibility will not change
+			// checkVisibilityChanged();
+		}
+		return modified;
+	}
+
+	/**
+	 * Add all groups in {@code collection} to the state. Returns {@code true},
+	 * if at least one group was added. Returns {@code false}, if all groups
+	 * were already present.
+	 * <p>
+	 * If any groups are added and no other group was current, then the first
+	 * added groups will be made current.
+	 *
+	 * @return {@code true}, if list of groups changed as a result of the call.
+	 *
+	 * @throws NullPointerException
+	 *     if {@code collection == null} or any element of {@code collection} is
+	 *     {@code null}.
+	 */
+	@Override
+	public boolean addGroups( final Collection< ? extends SourceGroup > collection )
+	{
+		checkAllNonNull( collection );
+
+		boolean modified = false;
+		boolean currentGroupChanged = false;
+		for ( final SourceGroup group : collection )
+		{
+			if ( groupIndices.containsKey( group ) )
+				continue;
+
+			modified = true;
+			final int nextIndex = groups.size();
+			groups.add( group );
+			groupData.put( group, new GroupData() );
+			groupIndices.put( group, nextIndex );
+			if ( currentGroup == null )
+			{
+				currentGroupChanged = true;
+				currentGroup = group;
+			}
+		}
+		if ( modified )
+		{
+			notifyListeners( NUM_GROUPS_CHANGED );
+			if ( currentGroupChanged )
+				notifyListeners( CURRENT_GROUP_CHANGED );
+			// new groups are empty, so visibility will not change
+			// checkVisibilityChanged();
+		}
+		return modified;
+	}
+
+	/**
+	 * Remove {@code group} from the state.
+	 * <p>
+	 * Returns {@code true}, if {@code group} was removed from the state.
+	 * Returns {@code false}, if {@code group} was not contained in state.
+	 * <p>
+	 * If {@code group} was current, then the first group in the list of groups
+	 * is made current (if it exists).
+	 *
+	 * @return {@code true}, if list of groups changed as a result of the call
+	 *
+	 * @throws NullPointerException
+	 *     if {@code group == null}
+	 */
+	@Override
+	public boolean removeGroup( final SourceGroup group )
+	{
+		if ( group == null )
+			throw new NullPointerException();
+
+		final int removedIndex = groupIndices.remove( group );
+		final boolean modified = ( removedIndex != NO_ENTRY_VALUE );
+		if ( modified )
+		{
+			groups.remove( group );
+			for ( int i = removedIndex; i < groups.size(); ++i )
+				groupIndices.put( groups.get( i ), i );
+
+			groupData.remove( group );
+			activeGroups.remove( group );
+			final boolean currentGroupChanged = group.equals( currentGroup );
+			if ( currentGroupChanged )
+				currentGroup = groups.isEmpty() ? null : groups.get( 0 );
+
+			notifyListeners( NUM_GROUPS_CHANGED );
+			if ( currentGroupChanged )
+				notifyListeners( CURRENT_GROUP_CHANGED );
+			checkVisibilityChanged();
+		}
+		return modified;
+	}
+
+	/**
+	 * Remove all groups in {@code collection} from the state. Returns
+	 * {@code true}, if at least one group was removed. Returns {@code false},
+	 * if none of the groups was present.
+	 * <p>
+	 * If the current group was removed, then the first group in the remaining
+	 * list of groups is made current (if it exists).
+	 *
+	 * @return {@code true}, if list of groups changed as a result of the call.
+	 *
+	 * @throws NullPointerException
+	 *     if {@code collection == null} or any element of {@code collection} is
+	 *     {@code null}.
+	 */
+	@Override
+	public boolean removeGroups( final Collection< ? extends SourceGroup > collection )
+	{
+		checkAllNonNull( collection );
+
+		final boolean modified = groups.removeAll( collection );
+		final boolean currentGroupChanged = collection.contains( currentGroup );
+
+		if ( modified )
+		{
+			groupIndices.clear();
+			for ( int i = 0; i < groups.size(); ++i )
+				groupIndices.put( groups.get( i ), i );
+			groupData.keySet().removeAll( collection );
+			activeGroups.removeAll( collection );
+
+			if ( currentGroupChanged )
+				currentGroup = groups.isEmpty() ? null : groups.get( 0 );
+
+			notifyListeners( NUM_GROUPS_CHANGED );
+			if ( currentGroupChanged )
+				notifyListeners( CURRENT_GROUP_CHANGED );
+			checkVisibilityChanged();
+		}
+		return modified;
+	}
+
+	/**
+	 * Add {@code source} to {@code group}.
+	 * <p>
+	 * Returns {@code true}, if {@code source} was added to {@code group}.
+	 * Returns {@code false}, if {@code source} was already contained in
+	 * {@code group}. or either of {@code source} and {@code group} is not valid
+	 * (not in the BDV sources/groups list).
+	 *
+	 * @return {@code true}, if set of sources in {@code group} changed as a
+	 * result of the call
+	 *
+	 * @throws NullPointerException
+	 *     if {@code source == null} or {@code group == null}
+	 * @throws IllegalArgumentException
+	 *     if either of {@code source} and {@code group} is not contained in the
+	 *     state (and not {@code null}).
+	 */
+	@Override
+	public boolean addSourceToGroup( final SourceAndConverter< ? > source, final SourceGroup group )
+	{
+		checkSourcePresent( source );
+		checkGroupPresent( group );
+
+		final boolean modified = groupData.get( group ).sources.add( source );
+		if ( modified )
+		{
+			notifyListeners( SOURCE_TO_GROUP_ASSIGNMENT_CHANGED );
+			checkVisibilityChanged();
+		}
+		return modified;
+	}
+
+	/**
+	 * Add all sources in {@code collection} to {@code group}.
+	 * <p>
+	 * Returns {@code true}, if at least one source was added to {@code group}.
+	 * Returns {@code false}, if all sources were already contained in
+	 * {@code group}.
+	 *
+	 * @return {@code true}, if set of sources in {@code group} changed as a
+	 * result of the call
+	 *
+	 * @throws NullPointerException
+	 *     if {@code group == null} or {@code collection == null} or any element
+	 *     of {@code collection} is {@code null}.
+	 * @throws IllegalArgumentException
+	 *     if {@code group} or any element of {@code collection} is is not
+	 *     contained in the state (and not {@code null}).
+	 */
+	@Override
+	public boolean addSourcesToGroup( final Collection< ? extends SourceAndConverter< ? > > collection, final SourceGroup group )
+	{
+		checkSourcesPresent( collection );
+		checkGroupPresent( group );
+
+		final boolean modified = groupData.get( group ).sources.addAll( collection );
+		if ( modified )
+		{
+			notifyListeners( SOURCE_TO_GROUP_ASSIGNMENT_CHANGED );
+			checkVisibilityChanged();
+		}
+		return modified;
+	}
+
+	/**
+	 * Remove {@code source} from {@code group}.
+	 * <p>
+	 * Returns {@code true}, if {@code source} was removed from {@code group}.
+	 * Returns {@code false}, if {@code source} was not contained in
+	 * {@code group},
+	 *
+	 * @return {@code true}, if set of sources in {@code group} changed as a
+	 * result of the call
+	 *
+	 * @throws NullPointerException
+	 *     if {@code source == null} or {@code group == null}
+	 * @throws IllegalArgumentException
+	 *     if either of {@code source} and {@code group} is not contained in the
+	 *     state (and not {@code null}).
+	 */
+	@Override
+	public boolean removeSourceFromGroup( final SourceAndConverter< ? > source, final SourceGroup group )
+	{
+		checkSourcePresent( source );
+		checkGroupPresent( group );
+
+		final boolean modified = groupData.get( group ).sources.remove( source );
+		if ( modified )
+		{
+			notifyListeners( SOURCE_TO_GROUP_ASSIGNMENT_CHANGED );
+			checkVisibilityChanged();
+		}
+
+		return modified;
+	}
+
+	/**
+	 * Remove all sources in {@code collection} from {@code group}.
+	 * <p>
+	 * Returns {@code true}, if at least one source was removed from
+	 * {@code group}. Returns {@code false}, if none of the sources were
+	 * contained in {@code group}.
+	 *
+	 * @return {@code true}, if set of sources in {@code group} changed as a
+	 * result of the call
+	 *
+	 * @throws NullPointerException
+	 *     if {@code group == null} or {@code collection == null} or any element
+	 *     of {@code collection} is {@code null}.
+	 * @throws IllegalArgumentException
+	 *     if {@code group} or any element of {@code collection} is is not
+	 *     contained in the state (and not {@code null}).
+	 */
+	@Override
+	public boolean removeSourcesFromGroup( final Collection< ? extends SourceAndConverter< ? > > collection, final SourceGroup group )
+	{
+		checkSourcesPresent( collection );
+		checkGroupPresent( group );
+
+		final boolean modified = groupData.get( group ).sources.removeAll( collection );
+		if ( modified )
+		{
+			notifyListeners( SOURCE_TO_GROUP_ASSIGNMENT_CHANGED );
+			checkVisibilityChanged();
+		}
+
+		return modified;
+	}
+
+	/**
+	 * Get the set sources in {@code group}. The returned {@code Set} reflects
+	 * changes to the viewer state. It is unmodifiable and not thread-safe.
+	 *
+	 * @return the set of sources in {@code group}
+	 *
+	 * @throws NullPointerException
+	 *     if {@code group == null}
+	 * @throws IllegalArgumentException
+	 *     if {@code group} is not contained in the state (and not
+	 *     {@code null}).
+	 */
+	@Override
+	public Set< SourceAndConverter< ? > > getSourcesInGroup( final SourceGroup group )
+	{
+		checkGroupPresent( group );
+
+		return groupData.get( group ).unmodifiableSources;
+	}
+
+	/**
+	 * Remove all groups from the state.
+	 */
+	@Override
+	public void clearGroups()
+	{
+		if ( groups.isEmpty() )
+			return;
+
+		groups.clear();
+		groupIndices.clear();
+		activeGroups.clear();
+
+		final boolean currentGroupChanged = ( currentGroup != null );
+		currentGroup = null;
+
+		notifyListeners( NUM_GROUPS_CHANGED );
+		if ( currentGroupChanged )
+			notifyListeners( CURRENT_GROUP_CHANGED );
+		notifyListeners( SOURCE_TO_GROUP_ASSIGNMENT_CHANGED );
+		checkVisibilityChanged();
+	}
+
+	/**
+	 * Returns a {@link Comparator} that compares groups according to the order
+	 * in which they occur in the groups list. (Groups that do not occur in the
+	 * list are ordered before any group in the list).
+	 */
+	@Override
+	public Comparator< SourceGroup > groupOrder()
+	{
+		return Comparator.comparingInt( groupIndices::get );
+	}
+
+	// --------------------
+	// --    helpers     --
+	// --------------------
+
+	private class GroupData
+	{
+		String name;
+
+		final Set< SourceAndConverter< ? > > sources;
+
+		final Set< SourceAndConverter< ? > > unmodifiableSources;
+
+		GroupData()
+		{
+			name = null;
+			sources = new HashSet<>();
+			unmodifiableSources = Collections.unmodifiableSet( sources );
+		}
+	}
+
+	private class UnmodifiableSources extends WrappedList< SourceAndConverter< ? > >
+	{
+		public UnmodifiableSources()
+		{
+			super( Collections.unmodifiableList( sources ) );
+		}
+
+		@Override
+		public boolean contains( final Object o )
+		{
+			return sourceIndices.containsKey( o );
+		}
+
+		@Override
+		public boolean containsAll( final Collection< ? > c )
+		{
+			return sourceIndices.keySet().containsAll( c );
+		}
+
+		@Override
+		public int indexOf( final Object o )
+		{
+			return sourceIndices.get( o );
+		}
+
+		@Override
+		public int lastIndexOf( final Object o )
+		{
+			return sourceIndices.get( o );
+		}
+	}
+
+	private class UnmodifiableGroups extends WrappedList< SourceGroup >
+	{
+		public UnmodifiableGroups()
+		{
+			super( Collections.unmodifiableList( groups ) );
+		}
+
+		@Override
+		public boolean contains( final Object o )
+		{
+			return groupIndices.containsKey( o );
+		}
+
+		@Override
+		public boolean containsAll( final Collection< ? > c )
+		{
+			return groupIndices.keySet().containsAll( c );
+		}
+
+		@Override
+		public int indexOf( final Object o )
+		{
+			return groupIndices.get( o );
+		}
+
+		@Override
+		public int lastIndexOf( final Object o )
+		{
+			return groupIndices.get( o );
+		}
+	}
+
+	/**
+	 * @throws NullPointerException
+	 *     if {@code source == null}
+	 * @throws IllegalArgumentException
+	 *     if {@code source} is not contained in the state (and not
+	 *     {@code null}).
+	 */
+	private void checkSourcePresent( final SourceAndConverter< ? > source )
+	{
+		if ( source == null )
+			throw new NullPointerException();
+		if ( !sources.contains( source ) )
+			throw new IllegalArgumentException();
+	}
+
+	/**
+	 * @throws IllegalArgumentException
+	 *     if {@code source} is not contained in the state (and not
+	 *     {@code null}).
+	 */
+	private void checkSourcePresentAllowNull( final SourceAndConverter< ? > source )
+	{
+		if ( source != null && !sources.contains( source ) )
+			throw new IllegalArgumentException();
+	}
+
+	/**
+	 * @throws NullPointerException
+	 *     if {@code collection == null} or any element of {@code collection} is
+	 *     {@code null}.
+	 * @throws IllegalArgumentException
+	 *     if any element of {@code collection} is not contained in the state.
+	 */
+	private void checkSourcesPresent( final Collection< ? extends SourceAndConverter< ? > > collection )
+	{
+		if ( collection == null )
+			throw new NullPointerException();
+		for ( final SourceAndConverter< ? > source : collection )
+			checkSourcePresent( source );
+	}
+
+	/**
+	 * @throws NullPointerException
+	 *     if {@code group == null}
+	 * @throws IllegalArgumentException
+	 *     if {@code group} is not contained in the state (and not
+	 *     {@code null}).
+	 */
+	private void checkGroupPresent( final SourceGroup group )
+	{
+		if ( group == null )
+			throw new NullPointerException();
+		if ( !groups.contains( group ) )
+			throw new IllegalArgumentException();
+	}
+
+	/**
+	 * @throws IllegalArgumentException
+	 *     if {@code group} is not contained in the state (and not
+	 *     {@code null}).
+	 */
+	private void checkGroupPresentAllowNull( final SourceGroup group )
+	{
+		if ( group != null && !groups.contains( group ) )
+			throw new IllegalArgumentException();
+	}
+
+	/**
+	 * @throws NullPointerException
+	 *     if {@code collection == null} or any element of {@code collection} is
+	 *     {@code null}.
+	 * @throws IllegalArgumentException
+	 *     if any element of {@code collection} is not contained in the state.
+	 */
+	private void checkGroupsPresent( final Collection< ? extends SourceGroup > collection )
+	{
+		if ( collection == null )
+			throw new NullPointerException();
+		for ( final SourceGroup group : collection )
+			checkGroupPresent( group );
+	}
+
+	/**
+	 * @throws NullPointerException
+	 *     if {@code collection == null} or any element of {@code collection} is
+	 *     {@code null}.
+	 */
+	private void checkAllNonNull( final Collection< ? > collection )
+	{
+		if ( collection == null )
+			throw new NullPointerException();
+		for ( final Object e : collection )
+			if ( e == null )
+				throw new NullPointerException();
+	}
+
+	private void notifyListeners( final ViewerStateChange change )
+	{
+		listeners.list.forEach( l -> l.viewerStateChanged( change ) );
+	}
+
+	private void checkVisibilityChanged()
+	{
+		final Set< SourceAndConverter< ? > > visible = getVisibleSources();
+		if ( !visible.equals( previousVisibleSources ) )
+		{
+			previousVisibleSources.clear();
+			previousVisibleSources.addAll( visible );
+			notifyListeners( VISIBILITY_CHANGED );
+		}
+	}
+}
diff --git a/src/main/java/bdv/viewer/ConverterSetupBounds.java b/src/main/java/bdv/viewer/ConverterSetupBounds.java
new file mode 100644
index 0000000000000000000000000000000000000000..315041971f84c51c1a1f1f02f51d8008cfecfbde
--- /dev/null
+++ b/src/main/java/bdv/viewer/ConverterSetupBounds.java
@@ -0,0 +1,106 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.viewer;
+
+import bdv.tools.brightness.ConverterSetup;
+import bdv.viewer.SourceToConverterSetupBimap;
+import bdv.util.BoundedRange;
+import bdv.util.Bounds;
+import bdv.viewer.SourceAndConverter;
+import java.util.HashMap;
+import java.util.Map;
+import net.imglib2.type.numeric.ARGBType;
+import net.imglib2.type.numeric.IntegerType;
+import net.imglib2.type.volatiles.VolatileARGBType;
+
+/**
+ * Map from {@code ConverterSetup} to {@code Bounds} on the range bounds to put
+ * on the range slider.
+ *
+ * @author Tobias Pietzsch
+ */
+public class ConverterSetupBounds
+{
+	private final SourceToConverterSetupBimap bimap;
+
+	private final Map< ConverterSetup, Bounds > setupToBounds = new HashMap<>();
+
+	ConverterSetupBounds( final SourceToConverterSetupBimap bimap )
+	{
+		this.bimap = bimap;
+	}
+
+	public Bounds getBounds( final ConverterSetup setup )
+	{
+		return setupToBounds.compute( setup, this::getExtendedBounds );
+	}
+
+	public void setBounds( final ConverterSetup setup, final Bounds bounds )
+	{
+		setupToBounds.put( setup, bounds );
+
+		final double min = setup.getDisplayRangeMin();
+		final double max = setup.getDisplayRangeMax();
+		final BoundedRange range = new BoundedRange( min, max, min, max ).withMinBound( bounds.getMinBound() ).withMaxBound( bounds.getMaxBound() );
+		if ( range.getMin() != min || range.getMax() != max )
+			setup.setDisplayRange( range.getMin(), range.getMax() );
+	}
+
+	private Bounds getDefaultBounds( final ConverterSetup setup )
+	{
+		Bounds bounds = new Bounds( setup.getDisplayRangeMin(), setup.getDisplayRangeMax() );
+		final SourceAndConverter< ? > source = bimap.getSource( setup );
+		if ( source != null )
+		{
+			final Object type = source.getSpimSource().getType();
+
+			if ( type instanceof ARGBType || type instanceof VolatileARGBType )
+			{
+				bounds = bounds.join( new Bounds( 0, 255 ) );
+			}
+			else if ( type instanceof IntegerType )
+			{
+				final IntegerType< ? > integerType = ( IntegerType< ? > ) type;
+				bounds = bounds.join( new Bounds( integerType.getMinValue(), integerType.getMaxValue() ) );
+			}
+			else
+			{
+				bounds = bounds.join( new Bounds( 0, 1 ) );
+			}
+		}
+		return bounds;
+	}
+
+	private Bounds getExtendedBounds( final ConverterSetup setup, Bounds bounds )
+	{
+		if ( bounds == null )
+			bounds = getDefaultBounds( setup );
+		return bounds.join( new Bounds( setup.getDisplayRangeMin(), setup.getDisplayRangeMax() ) );
+	}
+}
diff --git a/src/main/java/bdv/viewer/ConverterSetups.java b/src/main/java/bdv/viewer/ConverterSetups.java
new file mode 100644
index 0000000000000000000000000000000000000000..93aaeb16650a7f54312b604b387622ec5ef5a7aa
--- /dev/null
+++ b/src/main/java/bdv/viewer/ConverterSetups.java
@@ -0,0 +1,147 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.viewer;
+
+import static bdv.viewer.ViewerStateChange.NUM_SOURCES_CHANGED;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+
+import org.scijava.listeners.Listeners;
+
+import bdv.tools.brightness.ConverterSetup;
+import bdv.tools.brightness.ConverterSetup.SetupChangeListener;
+
+/**
+ * Provides a mapping between {@link ConverterSetup}s and
+ * {@link SourceAndConverter sources}. All {@code setupParametersChanged()}
+ * events from ConverterSetup currently in the mapping are forwarded to
+ * registered {@link #listeners()}.
+ * <p>
+ * {@code ConverterSetups} listens to {@code ViewerState} changes. When a source
+ * is removed from the {@code ViewerState}, it is also removed from the mapping
+ * (as well as the corresponding {@code ConverterSetup}.
+ * <p>
+ * When <em>adding</em> sources, however, the corresponding
+ * {@code ConverterSetup} has to be manually registered using
+ * {@link #put(SourceAndConverter, ConverterSetup)}. (This is necessary to avoid
+ * breaking existing API.)
+ *
+ * @author Tobias Pietzsch
+ */
+public class ConverterSetups implements SourceToConverterSetupBimap
+{
+	private final Map< ConverterSetup, SourceAndConverter< ? > > setupToSource = new HashMap<>();
+
+	private final Map< SourceAndConverter< ? >, ConverterSetup > sourceToSetup = new HashMap<>();
+
+	private final ViewerState state;
+
+	private final BasicViewerState previousState;
+
+	private final SetupChangeListener converterSetupChangeListener;
+
+	private final Listeners.List< SetupChangeListener > forwardedSetupChangeListeners = new Listeners.SynchronizedList<>();
+
+	private final ConverterSetupBounds bounds;
+
+	public ConverterSetups( final ViewerState state )
+	{
+		this.state = state;
+		previousState = new BasicViewerState( state );
+		converterSetupChangeListener = setup -> forwardedSetupChangeListeners.list.forEach( l -> l.setupParametersChanged( setup ) );
+		state.changeListeners().add( this::analyzeChanges );
+		bounds = new ConverterSetupBounds( this );
+	}
+
+	/**
+	 * All {@code setupParametersChanged()} events from ConverterSetup currently
+	 * in the ViewerState are forwarded to these listeners.
+	 */
+	public Listeners< SetupChangeListener > listeners()
+	{
+		return forwardedSetupChangeListeners;
+	}
+
+	@Override
+	public SourceAndConverter< ? > getSource( final ConverterSetup setup )
+	{
+		return setupToSource.get( setup );
+	}
+
+	@Override
+	public ConverterSetup getConverterSetup( final SourceAndConverter< ? > source )
+	{
+		return sourceToSetup.get( source );
+	}
+
+	public synchronized void put( final SourceAndConverter< ? > source, final ConverterSetup setup )
+	{
+		final ConverterSetup previousSetup = sourceToSetup.put( source, setup );
+		final SourceAndConverter< ? > previousSource = setupToSource.put( setup, source );
+		setup.setupChangeListeners().add( converterSetupChangeListener );
+
+		// keep mapping one-to-one
+		if ( previousSetup != null && previousSetup != setup )
+		{
+			setupToSource.remove( previousSetup );
+			previousSetup.setupChangeListeners().remove( converterSetupChangeListener );
+		}
+		if ( previousSource != null && previousSource != source )
+			sourceToSetup.remove( previousSource );
+	}
+
+	public ConverterSetupBounds getBounds()
+	{
+		return bounds;
+	}
+
+	private synchronized void analyzeChanges( final ViewerStateChange change )
+	{
+		if ( change != NUM_SOURCES_CHANGED )
+			return;
+
+		final HashSet< SourceAndConverter< ? > > removedSources = new HashSet<>( previousState.getSources() );
+		removedSources.removeAll( state.getSources() );
+
+		// update ConverterSetup listeners
+		for ( final SourceAndConverter< ? > source : removedSources )
+		{
+			final ConverterSetup setup = sourceToSetup.remove( source );
+			if ( setup != null )
+			{
+				setupToSource.remove( setup );
+				setup.setupChangeListeners().remove( converterSetupChangeListener );
+			}
+		}
+
+		previousState.set( state );
+	}
+}
diff --git a/src/main/java/bdv/viewer/DisplayMode.java b/src/main/java/bdv/viewer/DisplayMode.java
index 6986364dcfb0b3a828dfeafc02209d79ba9c5205..6f6f8dfd6d7e63128f06b5e79ddd1fd01ffcff2d 100644
--- a/src/main/java/bdv/viewer/DisplayMode.java
+++ b/src/main/java/bdv/viewer/DisplayMode.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -55,6 +54,44 @@ public enum DisplayMode
 		return name;
 	}
 
+	public DisplayMode withFused( final boolean activate )
+	{
+		switch ( this )
+		{
+		default:
+		case SINGLE:
+		case FUSED:
+			return activate ? FUSED : SINGLE;
+		case GROUP:
+		case FUSEDGROUP:
+			return activate ? FUSEDGROUP : GROUP;
+		}
+	}
+
+	public DisplayMode withGrouping( final boolean activate )
+	{
+		switch ( this )
+		{
+		default:
+		case SINGLE:
+		case GROUP:
+			return activate ? GROUP : SINGLE;
+		case FUSED:
+		case FUSEDGROUP:
+			return activate ? FUSEDGROUP : FUSED;
+		}
+	}
+
+	public boolean hasFused()
+	{
+		return this == FUSED || this == FUSEDGROUP;
+	}
+
+	public boolean hasGrouping()
+	{
+		return this == GROUP || this == FUSEDGROUP;
+	}
+
 	public static final int length;
 
 	static
diff --git a/src/main/java/bdv/viewer/InteractiveDisplayCanvas.java b/src/main/java/bdv/viewer/InteractiveDisplayCanvas.java
new file mode 100644
index 0000000000000000000000000000000000000000..1efc46edff7f5c0e1789e21dcc119f073cd5e843
--- /dev/null
+++ b/src/main/java/bdv/viewer/InteractiveDisplayCanvas.java
@@ -0,0 +1,218 @@
+/*
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.viewer;
+
+import bdv.TransformEventHandler;
+import java.awt.Component;
+import java.awt.Dimension;
+import java.awt.Graphics;
+import java.awt.event.ComponentAdapter;
+import java.awt.event.ComponentEvent;
+import java.awt.event.FocusListener;
+import java.awt.event.KeyListener;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.awt.event.MouseListener;
+import java.awt.event.MouseMotionListener;
+import java.awt.event.MouseWheelListener;
+import javax.swing.JComponent;
+import org.scijava.listeners.Listeners;
+
+/*
+ * A {@link JComponent} that uses {@link OverlayRenderer OverlayRenderers}
+ * to render a canvas displayed on screen.
+ * <p>
+ * {@code InteractiveDisplayCanvas} has a {@code TransformEventHandler} that is notified when the component size is changed.
+ * <p>
+ * {@link #addHandler}/{@link #removeHandler} provide simplified that implement {@code MouseListener}, {@code KeyListener}, etc can be
+ *
+ * @param <A>
+ *            transform type
+ *
+ * @author Tobias Pietzsch
+ */
+public class InteractiveDisplayCanvas extends JComponent
+{
+	/**
+	 * Mouse/Keyboard handler that manipulates the view transformation.
+	 */
+	private TransformEventHandler handler;
+
+	/**
+	 * To draw this component, {@link OverlayRenderer#drawOverlays} is invoked for each renderer.
+	 */
+	final private Listeners.List< OverlayRenderer > overlayRenderers;
+
+	/**
+	 * Create a new {@code InteractiveDisplayCanvas}.
+	 *
+	 * @param width
+	 *            preferred component width.
+	 * @param height
+	 *            preferred component height.
+	 */
+	public InteractiveDisplayCanvas( final int width, final int height )
+	{
+		super();
+		setPreferredSize( new Dimension( width, height ) );
+		setFocusable( true );
+
+		overlayRenderers = new Listeners.SynchronizedList<>( r -> r.setCanvasSize( getWidth(), getHeight() ) );
+
+		addComponentListener( new ComponentAdapter()
+		{
+			@Override
+			public void componentResized( final ComponentEvent e )
+			{
+				final int w = getWidth();
+				final int h = getHeight();
+				// NB: Update of overlayRenderers needs to happen before update of handler
+				// Otherwise repaint might start before the render target receives the size change.
+				overlayRenderers.list.forEach( r -> r.setCanvasSize( w, h ) );
+				if ( handler != null )
+					handler.setCanvasSize( w, h, true );
+				// enableEvents( AWTEvent.MOUSE_MOTION_EVENT_MASK );
+			}
+		} );
+
+		addMouseListener( new MouseAdapter()
+		{
+			@Override
+			public void mousePressed( final MouseEvent e )
+			{
+				requestFocusInWindow();
+			}
+		} );
+	}
+
+	/**
+	 * OverlayRenderers can be added/removed here.
+	 * {@link OverlayRenderer#drawOverlays} is invoked for each renderer (in the order they were added).
+	 */
+	public Listeners< OverlayRenderer > overlays()
+	{
+		return overlayRenderers;
+	}
+
+	/**
+	 * Add new event handler. Depending on the interfaces implemented by
+	 * <code>handler</code> calls {@link Component#addKeyListener(KeyListener)},
+	 * {@link Component#addMouseListener(MouseListener)},
+	 * {@link Component#addMouseMotionListener(MouseMotionListener)},
+	 * {@link Component#addMouseWheelListener(MouseWheelListener)}.
+	 *
+	 * @param h handler to remove
+	 */
+	public void addHandler( final Object h )
+	{
+		if ( h instanceof KeyListener )
+			addKeyListener( ( KeyListener ) h );
+
+		if ( h instanceof MouseMotionListener )
+			addMouseMotionListener( ( MouseMotionListener ) h );
+
+		if ( h instanceof MouseListener )
+			addMouseListener( ( MouseListener ) h );
+
+		if ( h instanceof MouseWheelListener )
+			addMouseWheelListener( ( MouseWheelListener ) h );
+
+		if ( h instanceof FocusListener )
+			addFocusListener( ( FocusListener ) h );
+	}
+
+	/**
+	 * Remove an event handler. Add new event handler. Depending on the
+	 * interfaces implemented by <code>handler</code> calls
+	 * {@link Component#removeKeyListener(KeyListener)},
+	 * {@link Component#removeMouseListener(MouseListener)},
+	 * {@link Component#removeMouseMotionListener(MouseMotionListener)},
+	 * {@link Component#removeMouseWheelListener(MouseWheelListener)}.
+	 *
+	 * @param h handler to remove
+	 */
+	public void removeHandler( final Object h )
+	{
+		if ( h instanceof KeyListener )
+			removeKeyListener( ( KeyListener ) h );
+
+		if ( h instanceof MouseMotionListener )
+			removeMouseMotionListener( ( MouseMotionListener ) h );
+
+		if ( h instanceof MouseListener )
+			removeMouseListener( ( MouseListener ) h );
+
+		if ( h instanceof MouseWheelListener )
+			removeMouseWheelListener( ( MouseWheelListener ) h );
+
+		if ( h instanceof FocusListener )
+			removeFocusListener( ( FocusListener ) h );
+	}
+
+	/**
+	 * Set the {@link TransformEventHandler} that will be notified when component is resized.
+	 *
+	 * @param transformEventHandler
+	 *            handler to use
+	 */
+	public void setTransformEventHandler( final TransformEventHandler transformEventHandler )
+	{
+		if ( handler != null )
+			removeHandler( handler );
+		handler = transformEventHandler;
+		handler.setCanvasSize( getWidth(), getHeight(), false );
+		addHandler( handler );
+	}
+
+	@Override
+	public void paintComponent( final Graphics g )
+	{
+		overlayRenderers.list.forEach( r -> r.drawOverlays( g ) );
+	}
+
+	// -- deprecated API --
+
+	/**
+	 * @deprecated Use {@code overlays().add(renderer)} instead
+	 */
+	@Deprecated
+	public void addOverlayRenderer( final OverlayRenderer renderer )
+	{
+		overlayRenderers.add( renderer );
+	}
+
+	/**
+	 * @deprecated Use {@code overlays().remove(renderer)} instead
+	 */
+	@Deprecated
+	public void removeOverlayRenderer( final OverlayRenderer renderer )
+	{
+		overlayRenderers.remove( renderer );
+	}
+}
diff --git a/src/main/java/bdv/viewer/Interpolation.java b/src/main/java/bdv/viewer/Interpolation.java
index ab96371881c7da5e2d8e0302d68ab8f6bcd2e95a..c8719dc3962bf2caadcaf73b303af3d2077b239e 100644
--- a/src/main/java/bdv/viewer/Interpolation.java
+++ b/src/main/java/bdv/viewer/Interpolation.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/viewer/InterpolationModeListener.java b/src/main/java/bdv/viewer/InterpolationModeListener.java
index f754906a7fda1db00bbbf963ca6defaed18a117c..06cabdb7d8093b04e0268bacd43f4931496a65ee 100644
--- a/src/main/java/bdv/viewer/InterpolationModeListener.java
+++ b/src/main/java/bdv/viewer/InterpolationModeListener.java
@@ -1,9 +1,8 @@
 /*-
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -31,5 +30,5 @@ package bdv.viewer;
 
 public interface InterpolationModeListener
 {
-	public void interpolationModeChanged( final Interpolation mode );
+	void interpolationModeChanged( final Interpolation mode );
 }
diff --git a/src/main/java/bdv/viewer/NavigationActions.java b/src/main/java/bdv/viewer/NavigationActions.java
index 297a11a6a34279e88e2c86d02c66bdbcbdc2c397..5146ee374a3cb6b6102303dd1d2403957fc34b47 100644
--- a/src/main/java/bdv/viewer/NavigationActions.java
+++ b/src/main/java/bdv/viewer/NavigationActions.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -29,6 +28,8 @@
  */
 package bdv.viewer;
 
+import java.util.ArrayList;
+import java.util.List;
 import javax.swing.ActionMap;
 import javax.swing.InputMap;
 
@@ -90,23 +91,31 @@ public class NavigationActions extends Actions
 	public void modes( final ViewerPanel viewer )
 	{
 		runnableAction(
-				() -> viewer.toggleInterpolation(),
+				viewer::toggleInterpolation,
 				TOGGLE_INTERPOLATION, "I" );
 		runnableAction(
-				() -> viewer.getVisibilityAndGrouping().setFusedEnabled( !viewer.visibilityAndGrouping.isFusedEnabled() ),
+				() -> {
+					final ViewerState state = viewer.state();
+					final DisplayMode mode = state.getDisplayMode();
+					state.setDisplayMode( mode.withFused( !mode.hasFused() ) );
+				},
 				TOGGLE_FUSED_MODE, "F" );
 		runnableAction(
-				() -> viewer.getVisibilityAndGrouping().setGroupingEnabled( !viewer.visibilityAndGrouping.isGroupingEnabled() ),
+				() -> {
+					final ViewerState state = viewer.state();
+					final DisplayMode mode = state.getDisplayMode();
+					state.setDisplayMode( mode.withGrouping( !mode.hasGrouping() ) );
+				},
 				TOGGLE_GROUPING, "G" );
 	}
 
 	public void time( final ViewerPanel viewer )
 	{
 		runnableAction(
-				() -> viewer.nextTimePoint(),
+				viewer::nextTimePoint,
 				NEXT_TIMEPOINT, "CLOSE_BRACKET", "M" );
 		runnableAction(
-				() -> viewer.previousTimePoint(),
+				viewer::previousTimePoint,
 				PREVIOUS_TIMEPOINT, "OPEN_BRACKET", "N" );
 	}
 
@@ -117,10 +126,10 @@ public class NavigationActions extends Actions
 		{
 			final int sourceIndex = i;
 			runnableAction(
-					() -> viewer.getVisibilityAndGrouping().setCurrentGroupOrSource( sourceIndex ),
+					() -> setCurrentGroupOrSource( viewer, sourceIndex ),
 					String.format( SET_CURRENT_SOURCE, i ), numkeys[ i ] );
 			runnableAction(
-					() -> viewer.getVisibilityAndGrouping().toggleActiveGroupOrSource( sourceIndex ),
+					() -> toggleGroupOrSourceActive( viewer, sourceIndex ),
 					String.format( TOGGLE_SOURCE_VISIBILITY, i ), "shift " + numkeys[ i ] );
 		}
 	}
@@ -131,4 +140,59 @@ public class NavigationActions extends Actions
 		alignPlaneAction( viewer, AlignPlane.ZY, "shift X" );
 		alignPlaneAction( viewer, AlignPlane.XZ, "shift Y", "shift A" );
 	}
+
+	private static void setCurrentGroupOrSource( final ViewerPanel viewer, final int index )
+	{
+		final ViewerState state = viewer.state();
+		synchronized ( state )
+		{
+			if ( state.getDisplayMode().hasGrouping() )
+			{
+				final List< SourceGroup > groups = state.getGroups();
+				if ( index >= 0 && index < groups.size() )
+				{
+					final SourceGroup group = groups.get( index );
+					state.setCurrentGroup( group );
+					final List< SourceAndConverter< ? > > sources = new ArrayList<>( state.getSourcesInGroup( group ) );
+					if ( !sources.isEmpty() )
+					{
+						sources.sort( state.sourceOrder() );
+						state.setCurrentSource( sources.get( 0 ) );
+					}
+				}
+			}
+			else
+			{
+				final List< SourceAndConverter< ? > > sources = state.getSources();
+				if ( index >= 0 && index < sources.size() )
+					state.setCurrentSource( sources.get( index ) );
+			}
+		}
+	}
+
+	private static void toggleGroupOrSourceActive( final ViewerPanel viewer, final int index )
+	{
+		final ViewerState state = viewer.state();
+		synchronized ( state )
+		{
+			if ( state.getDisplayMode().hasGrouping() )
+			{
+				final List< SourceGroup > groups = state.getGroups();
+				if ( index >= 0 && index < groups.size() )
+				{
+					final SourceGroup group = groups.get( index );
+					state.setGroupActive( group, !state.isGroupActive( group ) );
+				}
+			}
+			else
+			{
+				final List< SourceAndConverter< ? > > sources = state.getSources();
+				if ( index >= 0 && index < sources.size() )
+				{
+					final SourceAndConverter< ? > source = sources.get( index );
+					state.setSourceActive( source, !state.isSourceActive( source ) );
+				}
+			}
+		}
+	}
 }
diff --git a/src/main/java/bdv/viewer/OverlayRenderer.java b/src/main/java/bdv/viewer/OverlayRenderer.java
new file mode 100644
index 0000000000000000000000000000000000000000..a815ee95cbb9b380b59265aa1a3a7770e03bcdd1
--- /dev/null
+++ b/src/main/java/bdv/viewer/OverlayRenderer.java
@@ -0,0 +1,58 @@
+/*
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.viewer;
+
+import java.awt.Graphics;
+
+/**
+ * Draw something to a {@link Graphics} canvas and receive notifications about
+ * changes of the canvas size.
+ *
+ * @author Tobias Pietzsch
+ */
+public interface OverlayRenderer
+{
+	/**
+	 * Render overlays.
+	 */
+	void drawOverlays( Graphics g );
+
+	/**
+	 * This is called, when the screen size of the canvas (the component
+	 * displaying the image and generating mouse events) changes. This can be
+	 * used to determine scale of overlay or screen coordinates relative to the
+	 * border.
+	 *
+	 * @param width
+	 *            the new canvas width.
+	 * @param height
+	 *            the new canvas height.
+	 */
+	void setCanvasSize( int width, int height );
+}
diff --git a/src/main/java/bdv/viewer/RequestRepaint.java b/src/main/java/bdv/viewer/RequestRepaint.java
index b80179cb39ec3349404aa448dbd34226ee3c3a82..a6eedd483db18dbca2a31166a591f42c189c4edd 100644
--- a/src/main/java/bdv/viewer/RequestRepaint.java
+++ b/src/main/java/bdv/viewer/RequestRepaint.java
@@ -1,9 +1,8 @@
 /*-
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -34,5 +33,5 @@ public interface RequestRepaint
 	/**
 	 * Repaint as soon as possible.
 	 */
-	public void requestRepaint();
+	void requestRepaint();
 }
diff --git a/src/main/java/bdv/viewer/Source.java b/src/main/java/bdv/viewer/Source.java
index b623521c776a229a1a720a7de8ca57b4398bf315..434ec35fc3bd430d22e8e0cff878ff865521d66c 100644
--- a/src/main/java/bdv/viewer/Source.java
+++ b/src/main/java/bdv/viewer/Source.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -45,7 +44,7 @@ import net.imglib2.realtransform.AffineTransform3D;
  * {@link TimePoint#getId() id}. This timepoint index is an index into the
  * ordered list of timepoints {@link TimePoints#getTimePointsOrdered()}.
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
 public interface Source< T >
 {
@@ -56,7 +55,7 @@ public interface Source< T >
 	 *            timepoint index
 	 * @return true, if there is data for timepoint index t.
 	 */
-	public boolean isPresent( int t );
+	boolean isPresent( int t );
 
 	/**
 	 * Get the 3D stack at timepoint index t.
@@ -67,8 +66,24 @@ public interface Source< T >
 	 * 			  mipmap level
 	 * @return the {@link RandomAccessibleInterval stack}.
 	 */
-	public RandomAccessibleInterval< T > getSource( int t, int level );
+	RandomAccessibleInterval< T > getSource( int t, int level );
 
+	/**
+	 * Whether this source participates in bounding box culling.
+	 * <p>
+	 * If {@code true}, then this source will only be rendered if its bounding
+	 * box, i.e., the interval of {@link #getSource}, intersects the
+	 * current screen area (when transformed to viewer coordinates).
+	 * <p>
+	 * If {@code false}, then this source will be always rendered (if it is
+	 * set to be visible.)
+	 *
+	 * @return {@code true}, if this source participates in bounding box culling.
+	 */
+	default boolean doBoundingBoxCulling()
+	{
+		return true;
+	}
 
 	/**
 	 * Get the 3D stack at timepoint index t, extended to infinity and interpolated.
@@ -81,15 +96,7 @@ public interface Source< T >
 	 * 			  interpolation method to use
 	 * @return the extended and interpolated {@link RandomAccessible stack}.
 	 */
-	public RealRandomAccessible< T > getInterpolatedSource( final int t, final int level, final Interpolation method );
-
-	/*
-	 * TODO: Consider adding the methods for getting Source with explicit
-	 * ThreadGroup key (in case we don't want it to be the current thread's
-	 * ThreadGroup).
-	 */
-//	public default RandomAccessibleInterval< T > getSource( int t, int level, ThreadGroup threadGroup );
-//	public RealRandomAccessible< T > getInterpolatedSource( final int t, final int level, final Interpolation method, ThreadGroup threadGroup );
+	RealRandomAccessible< T > getInterpolatedSource( final int t, final int level, final Interpolation method );
 
 	/**
 	 * Get the transform from the {@link #getSource(int, int) source} at the
@@ -103,26 +110,26 @@ public interface Source< T >
 	 *            is set to the source-to-global transform, that transforms
 	 *            source coordinates into the global coordinates
 	 */
-	public void getSourceTransform( int t, int level, AffineTransform3D transform );
+	void getSourceTransform( int t, int level, AffineTransform3D transform );
 
 	/**
 	 * Get an instance of the pixel type.
 	 * @return instance of pixel type.
 	 */
-	public T getType();
+	T getType();
 
 	/**
 	 * Get the name of the source.
 	 * @return the name of the source.
 	 */
-	public String getName();
+	String getName();
 
 	/**
 	 * Get voxel size and unit for this source. May return null.
 	 *
 	 * @return voxel size and unit or {@code null}.
 	 */
-	public VoxelDimensions getVoxelDimensions();
+	VoxelDimensions getVoxelDimensions();
 
-	public int getNumMipmapLevels();
+	int getNumMipmapLevels();
 }
diff --git a/src/main/java/bdv/viewer/SourceAndConverter.java b/src/main/java/bdv/viewer/SourceAndConverter.java
index d42825791237ec4f10cc3d5d43b85064e4216a4d..223cae00e91035bfed3197b34e66fcf518555b57 100644
--- a/src/main/java/bdv/viewer/SourceAndConverter.java
+++ b/src/main/java/bdv/viewer/SourceAndConverter.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/viewer/SourceGroup.java b/src/main/java/bdv/viewer/SourceGroup.java
new file mode 100644
index 0000000000000000000000000000000000000000..0e747004597e5c272b581cfad348282d4ec8e9aa
--- /dev/null
+++ b/src/main/java/bdv/viewer/SourceGroup.java
@@ -0,0 +1,36 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.viewer;
+
+/**
+ * A SourceGroup handle. Identifies the same source group over time (while both
+ * the group's name and associated sources change).
+ */
+public final class SourceGroup
+{}
diff --git a/src/main/java/bdv/viewer/SourceToConverterSetupBimap.java b/src/main/java/bdv/viewer/SourceToConverterSetupBimap.java
new file mode 100644
index 0000000000000000000000000000000000000000..48e2c0cfa070ce7f9d384abf5a806badc8729fb9
--- /dev/null
+++ b/src/main/java/bdv/viewer/SourceToConverterSetupBimap.java
@@ -0,0 +1,62 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.viewer;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import bdv.tools.brightness.ConverterSetup;
+
+/**
+ * Associates {@code ConverterSetup}s to sources ({@code SourceAndConverter}).
+ *
+ * @author Tobias Pietzsch
+ */
+public interface SourceToConverterSetupBimap
+{
+	SourceAndConverter< ? > getSource( ConverterSetup converterSetup );
+
+	ConverterSetup getConverterSetup( SourceAndConverter< ? > source );
+
+	/**
+	 * Returns list of all non-{@code null} ConverterSetups associated to
+	 * {@code sources}.
+	 */
+	default List< ConverterSetup > getConverterSetups( final List< ? extends SourceAndConverter< ? > > sources )
+	{
+		final List< ConverterSetup > converterSetups = new ArrayList<>();
+		for ( final SourceAndConverter< ? > source : sources )
+		{
+			final ConverterSetup converterSetup = getConverterSetup( source );
+			if ( converterSetup != null )
+				converterSetups.add( converterSetup );
+		}
+		return converterSetups;
+	}
+}
diff --git a/src/main/java/bdv/viewer/SynchronizedViewerState.java b/src/main/java/bdv/viewer/SynchronizedViewerState.java
new file mode 100644
index 0000000000000000000000000000000000000000..754d732252ff3876e7d446085dd95b72bde63339
--- /dev/null
+++ b/src/main/java/bdv/viewer/SynchronizedViewerState.java
@@ -0,0 +1,1102 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.viewer;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Set;
+
+import net.imglib2.realtransform.AffineTransform3D;
+
+import org.scijava.listeners.Listeners;
+
+/**
+ * Maintains the BigDataViewer state and implements {@link ViewerState} to
+ * expose query and modification methods. {@code ViewerStateChangeListener}s can
+ * be registered and will be notified about various {@link ViewerStateChange
+ * state changes}.
+ * <p>
+ * All methods of this class are {@code synchronized}, so that every individual
+ * change to the viewer state is atomic. {@code IllegalArgumentException}s
+ * thrown by the wrapped {@code BasicViewerState} are silently swallowed, under
+ * the assumption that they result from concurrent changes (for example another
+ * thread might have removed the source that you are trying to make current).
+ * <p>
+ * To perform sequences of operations atomically explicit synchronization is
+ * required. In particular, this is true when using the collections returned by
+ * {@link #getSources()}, {@link #getActiveSources()}, {@link #getGroups()},
+ * {@link #getActiveGroups()}, and {@link #getSourcesInGroup(SourceGroup)}.
+ * These collections are backed by the ViewerState, they reflect changes, and
+ * they are <em>not thread-safe</em>. It is possible to run into
+ * {@code ConcurrentModificationException} when iterating them, etc.
+ * <p>
+ * Example where explicit synchronization is required:
+ * <pre>{@code
+ * List<SourceGroup> groupsContainingCurrentSource;
+ * synchronized (state) {
+ *     SourceAndConverter<?> currentSource = state.getCurrentSource();
+ *     groupsContainingCurrentSource =
+ *         state.getGroups().stream()
+ *             .filter(g -> state.getSourcesInGroup(g).contains(currentSource))
+ *             .collect(Collectors.toList());
+ * }}</pre>
+ * <p>
+ * Alternatively, for read-only access, it is possible to (atomically) take an
+ * unmodifiable {@link #snapshot()} of the current state.
+ *
+ * @author Tobias Pietzsch
+ */
+public class SynchronizedViewerState implements ViewerState
+{
+	private static final boolean DEBUG = false;
+
+	private final ViewerState state;
+
+	public SynchronizedViewerState( final ViewerState state )
+	{
+		this.state = state;
+	}
+
+	@Override
+	public Listeners< ViewerStateChangeListener > changeListeners()
+	{
+		return state.changeListeners();
+	}
+
+	/**
+	 * Get a snapshot of this ViewerState.
+	 *
+	 * @return unmodifiable copy of the current state
+	 */
+	@Override
+	public synchronized ViewerState snapshot()
+	{
+		return state.snapshot();
+	}
+
+	@Override
+	public synchronized Interpolation getInterpolation()
+	{
+		return state.getInterpolation();
+	}
+
+	@Override
+	public synchronized void setInterpolation( final Interpolation interpolation )
+	{
+		state.setInterpolation( interpolation );
+	}
+
+	@Override
+	public synchronized DisplayMode getDisplayMode()
+	{
+		return state.getDisplayMode();
+	}
+
+	@Override
+	public synchronized void setDisplayMode( final DisplayMode mode )
+	{
+		state.setDisplayMode( mode );
+	}
+
+	@Override
+	public synchronized int getNumTimepoints()
+	{
+		return state.getNumTimepoints();
+	}
+
+	@Override
+	public synchronized void setNumTimepoints( final int n )
+	{
+		state.setNumTimepoints( n );
+	}
+
+	@Override
+	public synchronized int getCurrentTimepoint()
+	{
+		return state.getCurrentTimepoint();
+	}
+
+	@Override
+	public synchronized void setCurrentTimepoint( final int t )
+	{
+		try
+		{
+			state.setCurrentTimepoint( t );
+		}
+		catch ( final IllegalArgumentException e )
+		{
+			if ( DEBUG )
+				e.printStackTrace();
+		}
+	}
+
+	@Override
+	public synchronized void getViewerTransform( final AffineTransform3D transform )
+	{
+		state.getViewerTransform( transform );
+	}
+
+	@Override
+	public synchronized void setViewerTransform( final AffineTransform3D transform )
+	{
+		state.setViewerTransform( transform );
+	}
+
+	// --------------------
+	// --    sources     --
+	// --------------------
+
+	/**
+	 * Get the list of sources. The returned {@code List} reflects changes to
+	 * the viewer state. It is unmodifiable and <em>not thread-safe</em>.
+	 * <p>
+	 * The returned list is also a set, there are no duplicate entries.
+	 *
+	 * @return the list of sources
+	 */
+	@Override
+	public synchronized List< SourceAndConverter< ? > > getSources()
+	{
+		return state.getSources();
+	}
+
+	/**
+	 * Get the current source. (May return {@code null} if there is no current
+	 * source)
+	 *
+	 * @return the current source
+	 */
+	@Override
+	public synchronized SourceAndConverter< ? > getCurrentSource()
+	{
+		return state.getCurrentSource();
+	}
+
+	/**
+	 * Returns {@code true} if {@code source} is the current source. Equivalent
+	 * to {@code (getCurrentSource() == source)}.
+	 *
+	 * @param source
+	 *     the source. Passing {@code null} checks whether no source is current.
+	 *
+	 * @return {@code true} if {@code source} is the current source
+	 */
+	@Override
+	public synchronized boolean isCurrentSource( final SourceAndConverter< ? > source )
+	{
+		return state.isCurrentSource( source );
+	}
+
+	/**
+	 * Make {@code source} the current source. Returns {@code true}, if current
+	 * source changes as a result of the call. Returns {@code false}, if
+	 * {@code source} is already the current source.
+	 * <p>
+	 * Also returns {@code false}, if {@code source} is not valid (for example
+	 * because it has been removed from the state by a different thread).
+	 *
+	 * @param source
+	 *     the source to make current. Passing {@code null} clears the current
+	 *     source.
+	 *
+	 * @return {@code true}, if current source changed as a result of the call
+	 */
+	@Override
+	public synchronized boolean setCurrentSource( final SourceAndConverter< ? > source )
+	{
+		try
+		{
+			return state.setCurrentSource( source );
+		}
+		catch ( final IllegalArgumentException e )
+		{
+			if ( DEBUG )
+				e.printStackTrace();
+			return false;
+		}
+	}
+
+	/**
+	 * Get the set of active sources. The returned {@code Set} reflects changes
+	 * to the viewer state. It is unmodifiable and <em>not thread-safe</em>.
+	 *
+	 * @return the set of active sources
+	 */
+	@Override
+	public synchronized Set< SourceAndConverter< ? > > getActiveSources()
+	{
+		return state.getActiveSources();
+	}
+
+	/**
+	 * Check whether the given {@code source} is active.
+	 *
+	 * Returns {@code true}, if {@code source} is active. Returns {@code false},
+	 * if {@code source} is inactive or not valid (for example because it has
+	 * been removed from the state by a different thread).
+	 *
+	 * @return {@code true}, if {@code source} is active
+	 *
+	 * @throws NullPointerException
+	 *     if {@code source == null}
+	 */
+	@Override
+	public synchronized boolean isSourceActive( final SourceAndConverter< ? > source )
+	{
+		try
+		{
+			return state.isSourceActive( source );
+		}
+		catch ( final IllegalArgumentException e )
+		{
+			if ( DEBUG )
+				e.printStackTrace();
+			return false;
+		}
+	}
+
+	/**
+	 * Set {@code source} active or inactive.
+	 * <p>
+	 * Returns {@code true}, if source activity changes as a result of the call.
+	 * Returns {@code false}, if {@code source} is already in the desired
+	 * {@code active} state.
+	 * <p>
+	 * Also returns {@code false}, if {@code source} is not valid (for example
+	 * because it has been removed from the state by a different thread).
+	 *
+	 * @return {@code true}, if source activity changed as a result of the call
+	 *
+	 * @throws NullPointerException
+	 *     if {@code source == null}
+	 */
+	@Override
+	public synchronized boolean setSourceActive( final SourceAndConverter< ? > source, final boolean active )
+	{
+		try
+		{
+			return state.setSourceActive( source, active );
+		}
+		catch ( final IllegalArgumentException e )
+		{
+			if ( DEBUG )
+				e.printStackTrace();
+			return false;
+		}
+	}
+
+	/**
+	 * Set all sources in {@code collection} active or inactive.
+	 * <p>
+	 * Returns {@code true}, if source activity changes as a result of the call.
+	 * Returns {@code false}, if all sources were already in the desired
+	 * {@code active} state.
+	 * <p>
+	 * Does nothing and returns {@code false}, if any source in
+	 * {@code collection} is not valid (for example because it was removed from
+	 * the state by a different thread).
+	 *
+	 * @return {@code true}, if source activity changed as a result of the call
+	 *
+	 * @throws NullPointerException
+	 *     if {@code collection == null} or any element of {@code collection} is
+	 *     {@code null}.
+	 */
+	@Override
+	public synchronized boolean setSourcesActive( final Collection< ? extends SourceAndConverter< ? > > collection, final boolean active )
+	{
+		try
+		{
+			return state.setSourcesActive( collection, active );
+		}
+		catch ( final IllegalArgumentException e )
+		{
+			if ( DEBUG )
+				e.printStackTrace();
+			return false;
+		}
+	}
+
+	/**
+	 * Check whether the given {@code source} is visible.
+	 * <p>
+	 * Returns {@code false}, if {@code source} is not valid (for example
+	 * because it has been removed from the state by a different thread).
+	 * <p>
+	 * Whether a source is visible depends on the {@link #getDisplayMode()
+	 * display mode}:
+	 * <ul>
+	 * <li>In {@code DisplayMode.SINGLE} only the current source is visible.</li>
+	 * <li>In {@code DisplayMode.GROUP} the sources in the current group are visible.</li>
+	 * <li>In {@code DisplayMode.FUSED} all active sources are visible.</li>
+	 * <li>In {@code DisplayMode.FUSEDROUP} the sources in all active groups are visible.</li>
+	 * </ul>
+	 *
+	 * @return {@code true}, if {@code source} is visible
+	 *
+	 * @throws NullPointerException
+	 *     if {@code source == null}
+	 * @throws IllegalArgumentException
+	 *     if {@code source} is not contained in the state (and not
+	 *     {@code null}).
+	 */
+	@Override
+	public synchronized boolean isSourceVisible( final SourceAndConverter< ? > source )
+	{
+		try
+		{
+			return state.isSourceVisible( source );
+		}
+		catch ( final IllegalArgumentException e )
+		{
+			if ( DEBUG )
+				e.printStackTrace();
+			return false;
+		}
+	}
+
+	/**
+	 * Check whether the given {@code source} is both visible and provides image
+	 * data for the current timepoint.
+	 * <p>
+	 * Returns {@code false}, if {@code source} is not valid (for example
+	 * because it has been removed from the state by a different thread).
+	 *
+	 * Whether a source is visible depends on the {@link #getDisplayMode()
+	 * display mode}:
+	 * <ul>
+	 * <li>In {@code DisplayMode.SINGLE} only the current source is visible.</li>
+	 * <li>In {@code DisplayMode.GROUP} the sources in the current group are visible.</li>
+	 * <li>In {@code DisplayMode.FUSED} all active sources are visible.</li>
+	 * <li>In {@code DisplayMode.FUSEDROUP} the sources in all active groups are visible.</li>
+	 * </ul>
+	 * Additionally, the source must be {@link bdv.viewer.Source#isPresent(int)
+	 * present}, i.e., provide image data for the {@link #getCurrentTimepoint()
+	 * current timepoint}.
+	 *
+	 * @return {@code true}, if {@code source} is both visible and present
+	 *
+	 * @throws NullPointerException
+	 *     if {@code source == null}
+	 */
+	@Override
+	public synchronized boolean isSourceVisibleAndPresent( final SourceAndConverter< ? > source )
+	{
+		try
+		{
+			return state.isSourceVisibleAndPresent( source );
+		}
+		catch ( final IllegalArgumentException e )
+		{
+			if ( DEBUG )
+				e.printStackTrace();
+			return false;
+		}
+	}
+
+	/**
+	 * Get the set of visible sources.
+	 * <p>
+	 * The returned {@code Set} is a copy. Changes to the set will not be
+	 * reflected in the viewer state, and vice versa.
+	 * <p>
+	 * Whether a source is visible depends on the {@link #getDisplayMode()
+	 * display mode}:
+	 * <ul>
+	 * <li>In {@code DisplayMode.SINGLE} only the current source is visible.</li>
+	 * <li>In {@code DisplayMode.GROUP} the sources in the current group are visible.</li>
+	 * <li>In {@code DisplayMode.FUSED} all active sources are visible.</li>
+	 * <li>In {@code DisplayMode.FUSEDROUP} the sources in all active groups are visible.</li>
+	 * </ul>
+	 *
+	 * @return the set of visible sources
+	 */
+	@Override
+	public synchronized Set< SourceAndConverter< ? > > getVisibleSources()
+	{
+		return state.getVisibleSources();
+	}
+
+	/**
+	 * Get the set of visible sources that also provide image data for the
+	 * current timepoint.
+	 * <p>
+	 * The returned {@code Set} is a copy. Changes to the set will not be
+	 * reflected in the viewer state, and vice versa.
+	 * <p>
+	 * Whether a source is visible depends on the {@link #getDisplayMode()
+	 * display mode}:
+	 * <ul>
+	 * <li>In {@code DisplayMode.SINGLE} only the current source is visible.</li>
+	 * <li>In {@code DisplayMode.GROUP} the sources in the current group are visible.</li>
+	 * <li>In {@code DisplayMode.FUSED} all active sources are visible.</li>
+	 * <li>In {@code DisplayMode.FUSEDROUP} the sources in all active groups are visible.</li>
+	 * </ul>
+	 * Additionally, the source must be {@link bdv.viewer.Source#isPresent(int)
+	 * present}, i.e., provide image data for the {@link #getCurrentTimepoint()
+	 * current timepoint}.
+	 *
+	 * @return the set of sources that are both visible and present
+	 */
+	@Override
+	public synchronized Set< SourceAndConverter< ? > > getVisibleAndPresentSources()
+	{
+		return state.getVisibleAndPresentSources();
+	}
+
+	/**
+	 * Check whether the state contains the {@code source}.
+	 *
+	 * @return {@code true}, if {@code source} is in the list of sources.
+	 *
+	 * @throws NullPointerException
+	 *     if {@code source == null}
+	 */
+	@Override
+	public synchronized boolean containsSource( final SourceAndConverter< ? > source )
+	{
+		return state.containsSource( source );
+	}
+
+	/**
+	 * Add {@code source} to the state. Returns {@code true}, if the source is
+	 * added. Returns {@code false}, if the source is already present.
+	 * <p>
+	 * If {@code source} is added and no other source was current, then
+	 * {@code source} is made current
+	 *
+	 * @return {@code true}, if list of sources changed as a result of the call.
+	 *
+	 * @throws NullPointerException
+	 *     if {@code source == null}
+	 */
+	@Override
+	public synchronized boolean addSource( final SourceAndConverter< ? > source )
+	{
+		return state.addSource( source );
+	}
+
+	/**
+	 * Add all sources in {@code collection} to the state. Returns {@code true},
+	 * if at least one source was added. Returns {@code false}, if all sources
+	 * were already present.
+	 * <p>
+	 * If any sources are added and no other source was current, then the first
+	 * added sources will be made current.
+	 *
+	 * @return {@code true}, if list of sources changed as a result of the call.
+	 *
+	 * @throws NullPointerException
+	 *     if {@code collection == null} or any element of {@code collection} is
+	 *     {@code null}.
+	 */
+	@Override
+	public synchronized boolean addSources( final Collection< ? extends SourceAndConverter< ? > > collection )
+	{
+		return state.addSources( collection );
+	}
+
+	/**
+	 * Remove {@code source} from the state.
+	 * <p>
+	 * Returns {@code true}, if {@code source} was removed from the state.
+	 * Returns {@code false}, if {@code source} was not contained in state.
+	 * <p>
+	 * The {@code source} is also removed from any groups that contained it. If
+	 * {@code source} was current, then the first source in the list of sources
+	 * is made current (if it exists).
+	 *
+	 * @return {@code true}, if list of sources changed as a result of the call
+	 *
+	 * @throws NullPointerException
+	 *     if {@code source == null}
+	 */
+	@Override
+	public synchronized boolean removeSource( final SourceAndConverter< ? > source )
+	{
+		return state.removeSource( source );
+	}
+
+	/**
+	 * Remove all sources in {@code collection} from the state. Returns
+	 * {@code true}, if at least one source was removed. Returns {@code false},
+	 * if none of the sources was present.
+	 * <p>
+	 * Removed sources are also removed from any groups containing them. If the
+	 * current source was removed, then the first source in the remaining list
+	 * of sources is made current (if it exists).
+	 *
+	 * @return {@code true}, if list of sources changed as a result of the call.
+	 *
+	 * @throws NullPointerException
+	 *     if {@code collection == null} or any element of {@code collection} is
+	 *     {@code null}.
+	 */
+	@Override
+	public synchronized boolean removeSources( final Collection< ? extends SourceAndConverter< ? > > collection )
+	{
+		return state.removeSources( collection );
+	}
+
+	// TODO addSource with index
+	// TODO addSources with index
+
+	/**
+	 * Remove all sources from the state.
+	 */
+	@Override
+	public synchronized void clearSources()
+	{
+		state.clearSources();
+	}
+
+	/**
+	 * Returns a {@link Comparator} that compares sources according to the order
+	 * in which they occur in the sources list. (Sources that do not occur in
+	 * the list are ordered before any source in the list).
+	 */
+	@Override
+	public synchronized Comparator< SourceAndConverter< ? > > sourceOrder()
+	{
+		return state.sourceOrder();
+	}
+
+	// --------------------
+	// --     groups     --
+	// --------------------
+
+	/**
+	 * Get the list of groups. The returned {@code List} reflects changes to the
+	 * viewer state. It is unmodifiable and <em>not thread-safe</em>.
+	 * <p>
+	 * The returned list is also a set, there are no duplicate entries.
+	 *
+	 * @return the list of groups
+	 */
+	@Override
+	public synchronized List< SourceGroup > getGroups()
+	{
+		return state.getGroups();
+	}
+
+	/**
+	 * Get the current group. (May return {@code null} if there is no current
+	 * group)
+	 *
+	 * @return the current group
+	 */
+	@Override
+	public synchronized SourceGroup getCurrentGroup()
+	{
+		return state.getCurrentGroup();
+	}
+
+	/**
+	 * Returns {@code true} if {@code group} is the current group. Equivalent to
+	 * {@code (getCurrentGroup() == group)}.
+	 *
+	 * @param group
+	 *     the group. Passing {@code null} checks whether no group is current.
+	 *
+	 * @return {@code true} if {@code group} is the current group
+	 */
+	@Override
+	public synchronized boolean isCurrentGroup( final SourceGroup group )
+	{
+		return state.isCurrentGroup( group );
+	}
+
+	/**
+	 * Make {@code group} the current group. Returns {@code true}, if current
+	 * group changes as a result of the call. Returns {@code false}, if
+	 * {@code group} is already the current group.
+	 * <p>
+	 * Also returns {@code false}, if {@code group} is not valid (for example
+	 * because it has been removed from the state by a different thread).
+	 *
+	 * @param group
+	 *     the group to make current. Passing {@code null} clears the current
+	 *     group.
+	 *
+	 * @return {@code true}, if current group changed as a result of the call.
+	 */
+	@Override
+	public synchronized boolean setCurrentGroup( final SourceGroup group )
+	{
+		try
+		{
+			return state.setCurrentGroup( group );
+		}
+		catch ( final IllegalArgumentException e )
+		{
+			if ( DEBUG )
+				e.printStackTrace();
+			return false;
+		}
+	}
+
+	/**
+	 * Get the set of active groups. The returned {@code Set} reflects changes
+	 * to the viewer state. It is unmodifiable and not thread-safe.
+	 *
+	 * @return the set of active groups
+	 */
+	@Override
+	public synchronized Set< SourceGroup > getActiveGroups()
+	{
+		return state.getActiveGroups();
+	}
+
+	/**
+	 * Check whether the given {@code group} is active.
+	 *
+	 * Returns {@code true}, if {@code group} is active. Returns {@code false},
+	 * if {@code group} is inactive or not valid (for example because it has
+	 * been removed from the state by a different thread).
+	 *
+	 * @return {@code true}, if {@code group} is active
+	 *
+	 * @throws NullPointerException
+	 *     if {@code group == null}
+	 * @throws IllegalArgumentException
+	 *     if {@code group} is not contained in the state (and not
+	 *     {@code null}).
+	 */
+	@Override
+	public synchronized boolean isGroupActive( final SourceGroup group )
+	{
+		try
+		{
+			return state.isGroupActive( group );
+		}
+		catch ( final IllegalArgumentException e )
+		{
+			if ( DEBUG )
+				e.printStackTrace();
+			return false;
+		}
+	}
+
+	/**
+	 * Set {@code group} active or inactive.
+	 * <p>
+	 * Returns {@code true}, if group activity changes as a result of the call.
+	 * Returns {@code false}, if {@code group} is already in the desired
+	 * {@code active} state.
+	 * <p>
+	 * Also returns {@code false}, if {@code group} is not valid (for example
+	 * because it has been removed from the state by a different thread).
+	 *
+	 * @return {@code true}, if group activity changed as a result of the call
+	 *
+	 * @throws NullPointerException
+	 *     if {@code group == null}
+	 */
+	@Override
+	public synchronized boolean setGroupActive( final SourceGroup group, final boolean active )
+	{
+		try
+		{
+			return state.setGroupActive( group, active );
+		}
+		catch ( final IllegalArgumentException e )
+		{
+			if ( DEBUG )
+				e.printStackTrace();
+			return false;
+		}
+	}
+
+	/**
+	 * Set all groups in {@code collection} active or inactive.
+	 * <p>
+	 * Returns {@code true}, if group activity changes as a result of the call.
+	 * Returns {@code false}, if all groups were already in the desired
+	 * {@code active} state.
+	 * <p>
+	 * Does nothing and returns {@code false}, if any group in
+	 * {@code collection} is not valid (for example because it was removed from
+	 * the state by a different thread).
+	 *
+	 * @return {@code true}, if group activity changed as a result of the call
+	 *
+	 * @throws NullPointerException
+	 *     if {@code collection == null} or any element of {@code collection} is
+	 *     {@code null}.
+	 */
+	@Override
+	public synchronized boolean setGroupsActive( final Collection< ? extends SourceGroup > collection, final boolean active )
+	{
+		try
+		{
+			return state.setGroupsActive( collection, active );
+		}
+		catch ( final IllegalArgumentException e )
+		{
+			if ( DEBUG )
+				e.printStackTrace();
+			return false;
+		}
+	}
+
+	/**
+	 * Get the name of a {@code group}.
+	 * <p>
+	 * Returns {@code null}, if {@code group} is not valid (for example because
+	 * it has been removed from the state by a different thread), an empty set
+	 * is returned.
+	 *
+	 * @return name of the group, may be {@code null}
+	 *
+	 * @throws NullPointerException
+	 *     if {@code group == null}
+	 */
+	@Override
+	public synchronized String getGroupName( final SourceGroup group )
+	{
+		try
+		{
+			return state.getGroupName( group );
+		}
+		catch ( final IllegalArgumentException e )
+		{
+			if ( DEBUG )
+				e.printStackTrace();
+			return null;
+		}
+	}
+
+	/**
+	 * Set the {@code name} of a {@code group}.
+	 * <p>
+	 * Does nothing, if {@code group} is not valid (for example because it has
+	 * been removed from the state by a different thread), an empty set is
+	 * returned.
+	 *
+	 * @throws NullPointerException
+	 *     if {@code group == null}
+	 */
+	@Override
+	public synchronized void setGroupName( final SourceGroup group, final String name )
+	{
+		try
+		{
+			state.setGroupName( group, name );
+		}
+		catch ( final IllegalArgumentException e )
+		{
+			if ( DEBUG )
+				e.printStackTrace();
+		}
+	}
+
+	/**
+	 * Check whether the state contains the {@code group}.
+	 *
+	 * @return {@code true}, if {@code group} is in the list of groups.
+	 *
+	 * @throws NullPointerException
+	 *     if {@code group == null}
+	 */
+	@Override
+	public synchronized boolean containsGroup( final SourceGroup group )
+	{
+		return state.containsGroup( group );
+	}
+
+	/**
+	 * Add {@code group} to the state. Returns {@code true}, if the group is
+	 * added. Returns {@code false}, if the group is already present.
+	 * <p>
+	 * If {@code group} is added and no other group was current, then
+	 * {@code group} is made current
+	 *
+	 * @return {@code true}, if list of groups changed as a result of the call.
+	 *
+	 * @throws NullPointerException
+	 *     if {@code group == null}
+	 */
+	@Override
+	public synchronized boolean addGroup( final SourceGroup group )
+	{
+		return state.addGroup( group );
+	}
+
+	/**
+	 * Add all groups in {@code collection} to the state. Returns {@code true},
+	 * if at least one group was added. Returns {@code false}, if all groups
+	 * were already present.
+	 * <p>
+	 * If any groups are added and no other group was current, then the first
+	 * added groups will be made current.
+	 *
+	 * @return {@code true}, if list of groups changed as a result of the call.
+	 *
+	 * @throws NullPointerException
+	 *     if {@code collection == null} or any element of {@code collection} is
+	 *     {@code null}.
+	 */
+	@Override
+	public synchronized boolean addGroups( final Collection< ? extends SourceGroup > collection )
+	{
+		return state.addGroups( collection );
+	}
+
+	/**
+	 * Remove {@code group} from the state.
+	 * <p>
+	 * Returns {@code true}, if {@code group} was removed from the state.
+	 * Returns {@code false}, if {@code group} was not contained in state.
+	 * <p>
+	 * If {@code group} was current, then the first group in the list of groups
+	 * is made current (if it exists).
+	 *
+	 * @return {@code true}, if list of groups changed as a result of the call
+	 *
+	 * @throws NullPointerException
+	 *     if {@code group == null}
+	 */
+	@Override
+	public synchronized boolean removeGroup( final SourceGroup group )
+	{
+		return state.removeGroup( group );
+	}
+
+	/**
+	 * Remove all groups in {@code collection} from the state. Returns
+	 * {@code true}, if at least one group was removed. Returns {@code false},
+	 * if none of the groups was present.
+	 * <p>
+	 * If the current group was removed, then the first group in the remaining
+	 * list of groups is made current (if it exists).
+	 *
+	 * @return {@code true}, if list of groups changed as a result of the call.
+	 *
+	 * @throws NullPointerException
+	 *     if {@code collection == null} or any element of {@code collection} is
+	 *     {@code null}.
+	 */
+	@Override
+	public synchronized boolean removeGroups( final Collection< ? extends SourceGroup > collection )
+	{
+		return state.removeGroups( collection );
+	}
+
+	/**
+	 * Add {@code source} to {@code group}.
+	 * <p>
+	 * Returns {@code true}, if {@code source} was added to {@code group}.
+	 * Returns {@code false}, if {@code source} was already contained in
+	 * {@code group}. or either of {@code source} and {@code group} is not valid
+	 * (not in the BDV sources/groups list).
+	 * <p>
+	 * Does nothing and returns {@code false}, if {@code source} or
+	 * {@code group} are not valid (for example because they were removed from
+	 * the state by a different thread).
+	 *
+	 * @return {@code true}, if set of sources in {@code group} changed as a
+	 * result of the call
+	 *
+	 * @throws NullPointerException
+	 *     if {@code source == null} or {@code group == null}
+	 */
+	@Override
+	public synchronized boolean addSourceToGroup( final SourceAndConverter< ? > source, final SourceGroup group )
+	{
+		try
+		{
+			return state.addSourceToGroup( source, group );
+		}
+		catch ( final IllegalArgumentException e )
+		{
+			if ( DEBUG )
+				e.printStackTrace();
+			return false;
+		}
+	}
+
+	/**
+	 * Add all sources in {@code collection} to {@code group}.
+	 * <p>
+	 * Returns {@code true}, if at least one source was added to {@code group}.
+	 * Returns {@code false}, if all sources were already contained in
+	 * {@code group}.
+	 * <p>
+	 * Does nothing and returns {@code false}, if {@code group} or any source in
+	 * {@code collection} are not valid (for example because they were removed
+	 * from the state by a different thread).
+	 *
+	 * @return {@code true}, if set of sources in {@code group} changed as a
+	 * result of the call
+	 *
+	 * @throws NullPointerException
+	 *     if {@code group == null} or {@code collection == null} or any element
+	 *     of {@code collection} is {@code null}.
+	 */
+	@Override
+	public synchronized boolean addSourcesToGroup( final Collection< ? extends SourceAndConverter< ? > > collection, final SourceGroup group )
+	{
+		try
+		{
+			return state.addSourcesToGroup( collection, group );
+		}
+		catch ( final IllegalArgumentException e )
+		{
+			if ( DEBUG )
+				e.printStackTrace();
+			return false;
+		}
+	}
+
+	/**
+	 * Remove {@code source} from {@code group}.
+	 * <p>
+	 * Returns {@code true}, if {@code source} was removed from {@code group}.
+	 * Returns {@code false}, if {@code source} was not contained in
+	 * {@code group},
+	 * <p>
+	 * Does nothing and returns {@code false}, if {@code source} or
+	 * {@code group} are not valid (for example because they were removed from
+	 * the state by a different thread).
+	 *
+	 * @return {@code true}, if set of sources in {@code group} changed as a
+	 * result of the call
+	 *
+	 * @throws NullPointerException
+	 *     if {@code source == null} or {@code group == null}
+	 */
+	@Override
+	public synchronized boolean removeSourceFromGroup( final SourceAndConverter< ? > source, final SourceGroup group )
+	{
+		try
+		{
+			return state.removeSourceFromGroup( source, group );
+		}
+		catch ( final IllegalArgumentException e )
+		{
+			if ( DEBUG )
+				e.printStackTrace();
+			return false;
+		}
+	}
+
+	/**
+	 * Remove all sources in {@code collection} from {@code group}.
+	 * <p>
+	 * Returns {@code true}, if at least one source was removed from
+	 * {@code group}. Returns {@code false}, if none of the sources were
+	 * contained in {@code group}.
+	 * <p>
+	 * Does nothing and returns {@code false}, if {@code group} or any source in
+	 * {@code collection} are not valid (for example because they were removed
+	 * from the state by a different thread).
+	 *
+	 * @return {@code true}, if set of sources in {@code group} changed as a
+	 * result of the call
+	 *
+	 * @throws NullPointerException
+	 *     if {@code group == null} or {@code collection == null} or any element
+	 *     of {@code collection} is {@code null}.
+	 */
+	@Override
+	public synchronized boolean removeSourcesFromGroup( final Collection< ? extends SourceAndConverter< ? > > collection, final SourceGroup group )
+	{
+		try
+		{
+			return state.removeSourcesFromGroup( collection, group );
+		}
+		catch ( final IllegalArgumentException e )
+		{
+			if ( DEBUG )
+				e.printStackTrace();
+			return false;
+		}
+	}
+
+	/**
+	 * Get the set sources in {@code group}. The returned {@code Set} reflects
+	 * changes to the viewer state. It is unmodifiable and not thread-safe.
+	 * <p>
+	 * If {@code group} is not valid (for example because it has been removed
+	 * from the state by a different thread), an empty set is returned.
+	 *
+	 * @return the set of sources in {@code group}
+	 *
+	 * @throws NullPointerException
+	 *     if {@code group == null}
+	 */
+	@Override
+	public synchronized Set< SourceAndConverter< ? > > getSourcesInGroup( final SourceGroup group )
+	{
+		try
+		{
+			return state.getSourcesInGroup( group );
+		}
+		catch ( final IllegalArgumentException e )
+		{
+			if ( DEBUG )
+				e.printStackTrace();
+			return Collections.emptySet();
+		}
+	}
+
+	/**
+	 * Remove all groups from the state.
+	 */
+	@Override
+	public synchronized void clearGroups()
+	{
+		state.clearGroups();
+	}
+
+	/**
+	 * Returns a {@link Comparator} that compares groups according to the order
+	 * in which they occur in the groups list. (Groups that do not occur in the
+	 * list are ordered before any group in the list).
+	 */
+	@Override
+	public synchronized Comparator< SourceGroup > groupOrder()
+	{
+		return state.groupOrder();
+	}
+
+	/**
+	 * Returns the wrapped {@code ViewerState}.
+	 * <p>
+	 * <em>When using this, explicit synchronization (on this
+	 * {@code SynchronizedViewerState}) is required. PLEASE BE CAREFUL!</em>
+	 */
+	// TODO: REMOVE?
+	public ViewerState getWrappedState()
+	{
+		return state;
+	}
+}
diff --git a/src/main/java/bdv/viewer/TimePointListener.java b/src/main/java/bdv/viewer/TimePointListener.java
index 8181a4c1203f255c14e3b9b3180a504b9a8161d2..abd0d022b291825c966c1f06e1d721a83ecb5a3a 100644
--- a/src/main/java/bdv/viewer/TimePointListener.java
+++ b/src/main/java/bdv/viewer/TimePointListener.java
@@ -1,9 +1,8 @@
 /*-
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -31,5 +30,5 @@ package bdv.viewer;
 
 public interface TimePointListener
 {
-	public void timePointChanged( int timePointIndex );
+	void timePointChanged( int timePointIndex );
 }
diff --git a/src/main/java/bdv/viewer/TransformListener.java b/src/main/java/bdv/viewer/TransformListener.java
new file mode 100644
index 0000000000000000000000000000000000000000..512615632b67e7d48c7d8761c78fcf8c56aa9440
--- /dev/null
+++ b/src/main/java/bdv/viewer/TransformListener.java
@@ -0,0 +1,34 @@
+/*
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.viewer;
+
+public interface TransformListener< A >
+{
+	void transformChanged( A transform );
+}
diff --git a/src/main/java/bdv/viewer/UnmodifiableViewerState.java b/src/main/java/bdv/viewer/UnmodifiableViewerState.java
new file mode 100644
index 0000000000000000000000000000000000000000..061b214d4954c67e3959c71c2575006e8fc519a1
--- /dev/null
+++ b/src/main/java/bdv/viewer/UnmodifiableViewerState.java
@@ -0,0 +1,370 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.viewer;
+
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Set;
+import net.imglib2.realtransform.AffineTransform3D;
+import org.scijava.listeners.Listeners;
+
+/**
+ * Wraps another {@link ViewerState} and throws
+ * {@code UnsupportedOperationException} for all modification operations.
+ *
+ * @author Tobias Pietzsch
+ */
+class UnmodifiableViewerState implements ViewerState
+{
+	private final ViewerState state;
+
+	public UnmodifiableViewerState( final ViewerState state )
+	{
+		this.state = state;
+	}
+
+	@Override
+	public ViewerState snapshot()
+	{
+		return new UnmodifiableViewerState( state.snapshot() );
+	}
+
+	@Override
+	public Listeners< ViewerStateChangeListener > changeListeners()
+	{
+		return state.changeListeners();
+	}
+
+	@Override
+	public Interpolation getInterpolation()
+	{
+		return state.getInterpolation();
+	}
+
+	@Override
+	public void setInterpolation( final Interpolation i )
+	{
+		throw new UnsupportedOperationException();
+	}
+
+	@Override
+	public DisplayMode getDisplayMode()
+	{
+		return state.getDisplayMode();
+	}
+
+	@Override
+	public void setDisplayMode( final DisplayMode mode )
+	{
+		throw new UnsupportedOperationException();
+	}
+
+	@Override
+	public int getNumTimepoints()
+	{
+		return state.getNumTimepoints();
+	}
+
+	@Override
+	public void setNumTimepoints( final int n )
+	{
+		throw new UnsupportedOperationException();
+	}
+
+	@Override
+	public int getCurrentTimepoint()
+	{
+		return state.getCurrentTimepoint();
+	}
+
+	@Override
+	public void setCurrentTimepoint( final int t )
+	{
+		throw new UnsupportedOperationException();
+	}
+
+	@Override
+	public void getViewerTransform( final AffineTransform3D t )
+	{
+		state.getViewerTransform( t );
+	}
+
+	@Override
+	public void setViewerTransform( final AffineTransform3D t )
+	{
+		throw new UnsupportedOperationException();
+	}
+
+	@Override
+	public List< SourceAndConverter< ? > > getSources()
+	{
+		return state.getSources();
+	}
+
+	@Override
+	public SourceAndConverter< ? > getCurrentSource()
+	{
+		return state.getCurrentSource();
+	}
+
+	@Override
+	public boolean isCurrentSource( final SourceAndConverter< ? > source )
+	{
+		return state.isCurrentSource( source );
+	}
+
+	@Override
+	public boolean setCurrentSource( final SourceAndConverter< ? > source )
+	{
+		throw new UnsupportedOperationException();
+	}
+
+	@Override
+	public Set< SourceAndConverter< ? > > getActiveSources()
+	{
+		return state.getActiveSources();
+	}
+
+	@Override
+	public boolean isSourceActive( final SourceAndConverter< ? > source )
+	{
+		return state.isSourceActive( source );
+	}
+
+	@Override
+	public boolean setSourceActive( final SourceAndConverter< ? > source, final boolean active )
+	{
+		throw new UnsupportedOperationException();
+	}
+
+	@Override
+	public boolean setSourcesActive( final Collection< ? extends SourceAndConverter< ? > > collection, final boolean active )
+	{
+		throw new UnsupportedOperationException();
+	}
+
+	@Override
+	public boolean isSourceVisible( final SourceAndConverter< ? > source )
+	{
+		return state.isSourceVisible( source );
+	}
+
+	@Override
+	public boolean isSourceVisibleAndPresent( final SourceAndConverter< ? > source )
+	{
+		return state.isSourceVisibleAndPresent( source );
+	}
+
+	@Override
+	public Set< SourceAndConverter< ? > > getVisibleSources()
+	{
+		return state.getVisibleSources();
+	}
+
+	@Override
+	public Set< SourceAndConverter< ? > > getVisibleAndPresentSources()
+	{
+		return state.getVisibleAndPresentSources();
+	}
+
+	@Override
+	public boolean containsSource( final SourceAndConverter< ? > source )
+	{
+		return state.containsSource( source );
+	}
+
+	@Override
+	public boolean addSource( final SourceAndConverter< ? > source )
+	{
+		throw new UnsupportedOperationException();
+	}
+
+	@Override
+	public boolean addSources( final Collection< ? extends SourceAndConverter< ? > > collection )
+	{
+		throw new UnsupportedOperationException();
+	}
+
+	@Override
+	public boolean removeSource( final SourceAndConverter< ? > source )
+	{
+		throw new UnsupportedOperationException();
+	}
+
+	@Override
+	public boolean removeSources( final Collection< ? extends SourceAndConverter< ? > > collection )
+	{
+		throw new UnsupportedOperationException();
+	}
+
+	@Override
+	public void clearSources()
+	{
+		throw new UnsupportedOperationException();
+	}
+
+	@Override
+	public Comparator< SourceAndConverter< ? > > sourceOrder()
+	{
+		return state.sourceOrder();
+	}
+
+	@Override
+	public List< SourceGroup > getGroups()
+	{
+		return state.getGroups();
+	}
+
+	@Override
+	public SourceGroup getCurrentGroup()
+	{
+		return state.getCurrentGroup();
+	}
+
+	@Override
+	public boolean isCurrentGroup( final SourceGroup group )
+	{
+		return state.isCurrentGroup( group );
+	}
+
+	@Override
+	public boolean setCurrentGroup( final SourceGroup group )
+	{
+		throw new UnsupportedOperationException();
+	}
+
+	@Override
+	public Set< SourceGroup > getActiveGroups()
+	{
+		return state.getActiveGroups();
+	}
+
+	@Override
+	public boolean isGroupActive( final SourceGroup group )
+	{
+		return state.isGroupActive( group );
+	}
+
+	@Override
+	public boolean setGroupActive( final SourceGroup group, final boolean active )
+	{
+		throw new UnsupportedOperationException();
+	}
+
+	@Override
+	public boolean setGroupsActive( final Collection< ? extends SourceGroup > collection, final boolean active )
+	{
+		throw new UnsupportedOperationException();
+	}
+
+	@Override
+	public String getGroupName( final SourceGroup group )
+	{
+		return state.getGroupName( group );
+	}
+
+	@Override
+	public void setGroupName( final SourceGroup group, final String name )
+	{
+		throw new UnsupportedOperationException();
+	}
+
+	@Override
+	public boolean containsGroup( final SourceGroup group )
+	{
+		return state.containsGroup( group );
+	}
+
+	@Override
+	public boolean addGroup( final SourceGroup group )
+	{
+		throw new UnsupportedOperationException();
+	}
+
+	@Override
+	public boolean addGroups( final Collection< ? extends SourceGroup > collection )
+	{
+		throw new UnsupportedOperationException();
+	}
+
+	@Override
+	public boolean removeGroup( final SourceGroup group )
+	{
+		throw new UnsupportedOperationException();
+	}
+
+	@Override
+	public boolean removeGroups( final Collection< ? extends SourceGroup > collection )
+	{
+		throw new UnsupportedOperationException();
+	}
+
+	@Override
+	public boolean addSourceToGroup( final SourceAndConverter< ? > source, final SourceGroup group )
+	{
+		throw new UnsupportedOperationException();
+	}
+
+	@Override
+	public boolean addSourcesToGroup( final Collection< ? extends SourceAndConverter< ? > > collection, final SourceGroup group )
+	{
+		throw new UnsupportedOperationException();
+	}
+
+	@Override
+	public boolean removeSourceFromGroup( final SourceAndConverter< ? > source, final SourceGroup group )
+	{
+		throw new UnsupportedOperationException();
+	}
+
+	@Override
+	public boolean removeSourcesFromGroup( final Collection< ? extends SourceAndConverter< ? > > collection, final SourceGroup group )
+	{
+		throw new UnsupportedOperationException();
+	}
+
+	@Override
+	public Set< SourceAndConverter< ? > > getSourcesInGroup( final SourceGroup group )
+	{
+		return state.getSourcesInGroup( group );
+	}
+
+	@Override
+	public void clearGroups()
+	{
+		throw new UnsupportedOperationException();
+	}
+
+	@Override
+	public Comparator< SourceGroup > groupOrder()
+	{
+		return state.groupOrder();
+	}
+}
diff --git a/src/main/java/bdv/viewer/ViewerFrame.java b/src/main/java/bdv/viewer/ViewerFrame.java
index a0f156715b995375a12a1ec8c6c882a984564471..b6d273da6615fd2e10e41899e3b15fbfa349075c 100644
--- a/src/main/java/bdv/viewer/ViewerFrame.java
+++ b/src/main/java/bdv/viewer/ViewerFrame.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -29,6 +28,10 @@
  */
 package bdv.viewer;
 
+import bdv.TransformEventHandler;
+import bdv.ui.CardPanel;
+import bdv.ui.BdvDefaultCards;
+import bdv.ui.splitpanel.SplitPanel;
 import java.awt.BorderLayout;
 import java.awt.event.WindowAdapter;
 import java.awt.event.WindowEvent;
@@ -40,30 +43,35 @@ import javax.swing.SwingUtilities;
 import javax.swing.WindowConstants;
 
 import org.scijava.ui.behaviour.MouseAndKeyHandler;
+import org.scijava.ui.behaviour.util.Behaviours;
 import org.scijava.ui.behaviour.util.InputActionBindings;
 import org.scijava.ui.behaviour.util.TriggerBehaviourBindings;
 
-import bdv.BehaviourTransformEventHandler;
 import bdv.cache.CacheControl;
-import net.imglib2.ui.TransformEventHandler;
-import net.imglib2.ui.util.GuiUtil;
+import bdv.util.AWTUtils;
 
 /**
  * A {@link JFrame} containing a {@link ViewerPanel} and associated
  * {@link InputActionBindings}.
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
 public class ViewerFrame extends JFrame
 {
 	private static final long serialVersionUID = 1L;
 
-	protected final ViewerPanel viewer;
+	private final ViewerPanel viewer;
+
+	private final CardPanel cards;
+
+	private final SplitPanel splitPanel;
 
 	private final InputActionBindings keybindings;
 
 	private final TriggerBehaviourBindings triggerbindings;
 
+	private final ConverterSetups setups;
+
 	public ViewerFrame(
 			final List< SourceAndConverter< ? > > sources,
 			final int numTimepoints,
@@ -90,13 +98,21 @@ public class ViewerFrame extends JFrame
 			final ViewerOptions optional )
 	{
 //		super( "BigDataViewer", GuiUtil.getSuitableGraphicsConfiguration( GuiUtil.ARGB_COLOR_MODEL ) );
-		super( "BigDataViewer", GuiUtil.getSuitableGraphicsConfiguration( GuiUtil.RGB_COLOR_MODEL ) );
+		super( "BigDataViewer", AWTUtils.getSuitableGraphicsConfiguration( AWTUtils.RGB_COLOR_MODEL ) );
 		viewer = new ViewerPanel( sources, numTimepoints, cacheControl, optional );
+		setups = new ConverterSetups( viewer.state() );
+		setups.listeners().add( s -> viewer.requestRepaint() );
+
 		keybindings = new InputActionBindings();
 		triggerbindings = new TriggerBehaviourBindings();
 
+		cards = new CardPanel();
+		BdvDefaultCards.setup( cards, viewer, setups );
+		splitPanel = new SplitPanel( viewer, cards );
+
 		getRootPane().setDoubleBuffered( true );
-		add( viewer, BorderLayout.CENTER );
+//		add( viewer, BorderLayout.CENTER );
+		add( splitPanel, BorderLayout.CENTER );
 		pack();
 		setDefaultCloseOperation( WindowConstants.DISPOSE_ON_CLOSE );
 		addWindowListener( new WindowAdapter()
@@ -108,8 +124,8 @@ public class ViewerFrame extends JFrame
 			}
 		} );
 
-		SwingUtilities.replaceUIActionMap( getRootPane(), keybindings.getConcatenatedActionMap() );
-		SwingUtilities.replaceUIInputMap( getRootPane(), JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT, keybindings.getConcatenatedInputMap() );
+		SwingUtilities.replaceUIActionMap( viewer, keybindings.getConcatenatedActionMap() );
+		SwingUtilities.replaceUIInputMap( viewer, JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT, keybindings.getConcatenatedInputMap() );
 
 		final MouseAndKeyHandler mouseAndKeyHandler = new MouseAndKeyHandler();
 		mouseAndKeyHandler.setInputMap( triggerbindings.getConcatenatedInputTriggerMap() );
@@ -117,9 +133,12 @@ public class ViewerFrame extends JFrame
 		mouseAndKeyHandler.setKeypressManager( optional.values.getKeyPressedManager(), viewer.getDisplay() );
 		viewer.getDisplay().addHandler( mouseAndKeyHandler );
 
-		final TransformEventHandler< ? > tfHandler = viewer.getDisplay().getTransformEventHandler();
-		if ( tfHandler instanceof BehaviourTransformEventHandler )
-			( ( BehaviourTransformEventHandler< ? > ) tfHandler ).install( triggerbindings );
+		// TODO: should be a field?
+		final Behaviours transformBehaviours = new Behaviours( optional.values.getInputTriggerConfig(), "bdv" );
+		transformBehaviours.install( triggerbindings, "transform" );
+
+		final TransformEventHandler tfHandler = viewer.getTransformEventHandler();
+		tfHandler.install( transformBehaviours );
 	}
 
 	public ViewerPanel getViewerPanel()
@@ -127,6 +146,16 @@ public class ViewerFrame extends JFrame
 		return viewer;
 	}
 
+	public CardPanel getCardPanel()
+	{
+		return cards;
+	}
+
+	public SplitPanel getSplitPanel()
+	{
+		return splitPanel;
+	}
+
 	public InputActionBindings getKeybindings()
 	{
 		return keybindings;
@@ -136,4 +165,9 @@ public class ViewerFrame extends JFrame
 	{
 		return triggerbindings;
 	}
+
+	public ConverterSetups getConverterSetups()
+	{
+		return setups;
+	}
 }
diff --git a/src/main/java/bdv/viewer/ViewerOptions.java b/src/main/java/bdv/viewer/ViewerOptions.java
index 79d8f7fa19d0efed9c5a39b572f372e5e0855f8a..b977c4585e8fdfadafe7b32ffc2efb6bb0a283f4 100644
--- a/src/main/java/bdv/viewer/ViewerOptions.java
+++ b/src/main/java/bdv/viewer/ViewerOptions.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -29,12 +28,12 @@
  */
 package bdv.viewer;
 
+import bdv.TransformEventHandler3D;
 import java.awt.event.KeyListener;
 
 import org.scijava.ui.behaviour.KeyPressedManager;
 import org.scijava.ui.behaviour.io.InputTriggerConfig;
 
-import bdv.BehaviourTransformEventHandler3D;
 import bdv.viewer.animate.MessageOverlayAnimator;
 import bdv.viewer.render.AccumulateProjector;
 import bdv.viewer.render.AccumulateProjectorARGB;
@@ -42,12 +41,12 @@ import bdv.viewer.render.AccumulateProjectorFactory;
 import bdv.viewer.render.MultiResolutionRenderer;
 import net.imglib2.realtransform.AffineTransform3D;
 import net.imglib2.type.numeric.ARGBType;
-import net.imglib2.ui.TransformEventHandlerFactory;
+import bdv.TransformEventHandlerFactory;
 
 /**
  * Optional parameters for {@link ViewerPanel}.
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
 public class ViewerOptions
 {
@@ -111,19 +110,6 @@ public class ViewerOptions
 		return this;
 	}
 
-	/**
-	 * Set whether to used double buffered rendering.
-	 *
-	 * @param d
-	 *            Whether to use double buffered rendering.
-	 * @see MultiResolutionRenderer
-	 */
-	public ViewerOptions doubleBuffered( final boolean d )
-	{
-		values.doubleBuffered = d;
-		return this;
-	}
-
 	/**
 	 * Set how many threads to use for rendering.
 	 *
@@ -169,7 +155,7 @@ public class ViewerOptions
 		return this;
 	}
 
-	public ViewerOptions transformEventHandlerFactory( final TransformEventHandlerFactory< AffineTransform3D > f )
+	public ViewerOptions transformEventHandlerFactory( final TransformEventHandlerFactory f )
 	{
 		values.transformEventHandlerFactory = f;
 		return this;
@@ -232,8 +218,6 @@ public class ViewerOptions
 
 		private long targetRenderNanos = 30 * 1000000l;
 
-		private boolean doubleBuffered = true;
-
 		private int numRenderingThreads = 3;
 
 		private int numSourceGroups = 10;
@@ -242,7 +226,7 @@ public class ViewerOptions
 
 		private MessageOverlayAnimator msgOverlay = new MessageOverlayAnimator( 800 );
 
-		private TransformEventHandlerFactory< AffineTransform3D > transformEventHandlerFactory = BehaviourTransformEventHandler3D.factory();
+		private TransformEventHandlerFactory transformEventHandlerFactory = TransformEventHandler3D::new;
 
 		private AccumulateProjectorFactory< ARGBType > accumulateProjectorFactory = AccumulateProjectorARGB.factory;
 
@@ -257,7 +241,6 @@ public class ViewerOptions
 				height( height ).
 				screenScales( screenScales ).
 				targetRenderNanos( targetRenderNanos ).
-				doubleBuffered( doubleBuffered ).
 				numRenderingThreads( numRenderingThreads ).
 				numSourceGroups( numSourceGroups ).
 				useVolatileIfAvailable( useVolatileIfAvailable ).
@@ -287,11 +270,6 @@ public class ViewerOptions
 			return targetRenderNanos;
 		}
 
-		public boolean isDoubleBuffered()
-		{
-			return doubleBuffered;
-		}
-
 		public int getNumRenderingThreads()
 		{
 			return numRenderingThreads;
@@ -312,7 +290,7 @@ public class ViewerOptions
 			return msgOverlay;
 		}
 
-		public TransformEventHandlerFactory< AffineTransform3D > getTransformEventHandlerFactory()
+		public TransformEventHandlerFactory getTransformEventHandlerFactory()
 		{
 			return transformEventHandlerFactory;
 		}
diff --git a/src/main/java/bdv/viewer/ViewerPanel.java b/src/main/java/bdv/viewer/ViewerPanel.java
index ec19a2d83849addaa707e907ca440f68ca941a95..39bc9a54847fe633dd6451fd708e4ffc58bda556 100644
--- a/src/main/java/bdv/viewer/ViewerPanel.java
+++ b/src/main/java/bdv/viewer/ViewerPanel.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -29,15 +28,8 @@
  */
 package bdv.viewer;
 
-import static bdv.viewer.VisibilityAndGrouping.Event.CURRENT_SOURCE_CHANGED;
-import static bdv.viewer.VisibilityAndGrouping.Event.DISPLAY_MODE_CHANGED;
-import static bdv.viewer.VisibilityAndGrouping.Event.GROUP_ACTIVITY_CHANGED;
-import static bdv.viewer.VisibilityAndGrouping.Event.GROUP_NAME_CHANGED;
-import static bdv.viewer.VisibilityAndGrouping.Event.NUM_GROUPS_CHANGED;
-import static bdv.viewer.VisibilityAndGrouping.Event.NUM_SOURCES_CHANGED;
-import static bdv.viewer.VisibilityAndGrouping.Event.SOURCE_ACTVITY_CHANGED;
-import static bdv.viewer.VisibilityAndGrouping.Event.VISIBILITY_CHANGED;
-
+import bdv.TransformEventHandler;
+import bdv.TransformState;
 import java.awt.BorderLayout;
 import java.awt.Color;
 import java.awt.Font;
@@ -48,8 +40,8 @@ import java.awt.event.ComponentEvent;
 import java.awt.event.MouseEvent;
 import java.awt.event.MouseListener;
 import java.awt.event.MouseMotionListener;
-import java.lang.reflect.InvocationTargetException;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.List;
 import java.util.concurrent.CopyOnWriteArrayList;
@@ -63,14 +55,14 @@ import javax.swing.DefaultBoundedRangeModel;
 import javax.swing.JPanel;
 import javax.swing.JSlider;
 import javax.swing.SwingConstants;
-import javax.swing.event.ChangeEvent;
-import javax.swing.event.ChangeListener;
+import javax.swing.SwingUtilities;
 
+import net.imglib2.Interval;
+import bdv.viewer.render.awt.BufferedImageOverlayRenderer;
 import org.jdom2.Element;
 
 import bdv.cache.CacheControl;
 import bdv.util.Affine3DHelpers;
-import bdv.util.InvokeOnEDT;
 import bdv.util.Prefs;
 import bdv.viewer.animate.AbstractTransformAnimator;
 import bdv.viewer.animate.MessageOverlayAnimator;
@@ -82,9 +74,7 @@ import bdv.viewer.overlay.MultiBoxOverlayRenderer;
 import bdv.viewer.overlay.ScaleBarOverlayRenderer;
 import bdv.viewer.overlay.SourceInfoOverlayRenderer;
 import bdv.viewer.render.MultiResolutionRenderer;
-import bdv.viewer.render.TransformAwareBufferedImageOverlayRenderer;
 import bdv.viewer.state.SourceGroup;
-import bdv.viewer.state.SourceState;
 import bdv.viewer.state.ViewerState;
 import bdv.viewer.state.XmlIoViewerState;
 import net.imglib2.Positionable;
@@ -92,25 +82,21 @@ import net.imglib2.RealLocalizable;
 import net.imglib2.RealPoint;
 import net.imglib2.RealPositionable;
 import net.imglib2.realtransform.AffineTransform3D;
-import net.imglib2.ui.InteractiveDisplayCanvasComponent;
-import net.imglib2.ui.OverlayRenderer;
-import net.imglib2.ui.PainterThread;
-import net.imglib2.ui.TransformEventHandler;
-import net.imglib2.ui.TransformListener;
+import bdv.viewer.render.PainterThread;
 import net.imglib2.util.LinAlgHelpers;
-
+import org.scijava.listeners.Listeners;
 
 /**
  * A JPanel for viewing multiple of {@link Source}s. The panel contains a
- * {@link InteractiveDisplayCanvasComponent canvas} and a time slider (if there
+ * {@link InteractiveDisplayCanvas canvas} and a time slider (if there
  * are multiple time-points). Maintains a {@link ViewerState render state}, the
  * renderer, and basic navigation help overlays. It has it's own
  * {@link PainterThread} for painting, which is started on construction (use
  * {@link #stop() to stop the PainterThread}.
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
-public class ViewerPanel extends JPanel implements OverlayRenderer, TransformListener< AffineTransform3D >, PainterThread.Paintable, VisibilityAndGrouping.UpdateListener, RequestRepaint
+public class ViewerPanel extends JPanel implements OverlayRenderer, PainterThread.Paintable, ViewerStateChangeListener, RequestRepaint
 {
 	private static final long serialVersionUID = 1L;
 
@@ -128,7 +114,7 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 	/**
 	 * TODO
 	 */
-	protected final TransformAwareBufferedImageOverlayRenderer renderTarget;
+	protected final BufferedImageOverlayRenderer renderTarget;
 
 	/**
 	 * Overlay navigation boxes.
@@ -147,19 +133,18 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 	 */
 	protected final ScaleBarOverlayRenderer scaleBarOverlayRenderer;
 
-	/**
-	 * Transformation set by the interactive viewer.
-	 */
-	protected final AffineTransform3D viewerTransform;
+	private final TransformEventHandler transformEventHandler;
 
 	/**
 	 * Canvas used for displaying the rendered {@link #renderTarget image} and
 	 * overlays.
 	 */
-	protected final InteractiveDisplayCanvasComponent< AffineTransform3D > display;
+	protected final InteractiveDisplayCanvas display;
 
 	protected final JSlider sliderTime;
 
+	private boolean blockSliderTimeEvents;
+
 	/**
 	 * A {@link ThreadGroup} for (only) the threads used by this
 	 * {@link ViewerPanel}, that is, {@link #painterThread} and
@@ -191,20 +176,11 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 
 	/**
 	 * These listeners will be notified about changes to the
-	 * {@link #viewerTransform}. This is done <em>before</em> calling
+	 * viewer-transform. This is done <em>before</em> calling
 	 * {@link #requestRepaint()} so listeners have the chance to interfere.
 	 */
 	protected final CopyOnWriteArrayList< TransformListener< AffineTransform3D > > transformListeners;
 
-	/**
-	 * These listeners will be notified about changes to the
-	 * {@link #viewerTransform} that was used to render the current image. This
-	 * is intended for example for {@link OverlayRenderer}s that need to exactly
-	 * match the transform of their overlaid content to the transform of the
-	 * image.
-	 */
-	protected final CopyOnWriteArrayList< TransformListener< AffineTransform3D > > lastRenderTransformListeners;
-
 	/**
 	 * These listeners will be notified about changes to the current timepoint
 	 * {@link ViewerState#getCurrentTimepoint()}. This is done <em>before</em>
@@ -274,23 +250,21 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 		threadGroup = new ThreadGroup( this.toString() );
 		painterThread = new PainterThread( threadGroup, this );
 		painterThread.setDaemon( true );
-		viewerTransform = new AffineTransform3D();
-		display = new InteractiveDisplayCanvasComponent<>(
-				options.getWidth(), options.getHeight(), options.getTransformEventHandlerFactory() );
-		display.addTransformListener( this );
-		renderTarget = new TransformAwareBufferedImageOverlayRenderer();
-		renderTarget.setCanvasSize( options.getWidth(), options.getHeight() );
-		display.addOverlayRenderer( renderTarget );
-		display.addOverlayRenderer( this );
+		transformEventHandler = options.getTransformEventHandlerFactory().create(
+				TransformState.from( state()::getViewerTransform, state()::setViewerTransform ) );
+		renderTarget = new BufferedImageOverlayRenderer();
+		display = new InteractiveDisplayCanvas( options.getWidth(), options.getHeight() );
+		display.setTransformEventHandler( transformEventHandler );
+		display.overlays().add( renderTarget );
+		display.overlays().add( this );
 
 		renderingExecutorService = Executors.newFixedThreadPool(
 				options.getNumRenderingThreads(),
-				new RenderThreadFactory() );
+				new RenderThreadFactory( threadGroup ) );
 		imageRenderer = new MultiResolutionRenderer(
 				renderTarget, painterThread,
 				options.getScreenScales(),
 				options.getTargetRenderNanos(),
-				options.isDoubleBuffered(),
 				options.getNumRenderingThreads(),
 				renderingExecutorService,
 				options.isUseVolatileIfAvailable(),
@@ -301,25 +275,19 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 		display.addHandler( mouseCoordinates );
 
 		sliderTime = new JSlider( SwingConstants.HORIZONTAL, 0, numTimepoints - 1, 0 );
-		sliderTime.addChangeListener( new ChangeListener()
-		{
-			@Override
-			public void stateChanged( final ChangeEvent e )
-			{
-				if ( e.getSource().equals( sliderTime ) )
-					setTimepoint( sliderTime.getValue() );
-			}
+		sliderTime.addChangeListener( e -> {
+			if ( !blockSliderTimeEvents )
+				setTimepoint( sliderTime.getValue() );
 		} );
 
 		add( display, BorderLayout.CENTER );
 		if ( numTimepoints > 1 )
 			add( sliderTime, BorderLayout.SOUTH );
+		setFocusable( false );
 
 		visibilityAndGrouping = new VisibilityAndGrouping( state );
-		visibilityAndGrouping.addUpdateListener( this );
 
 		transformListeners = new CopyOnWriteArrayList<>();
-		lastRenderTransformListeners = new CopyOnWriteArrayList<>();
 		timePointListeners = new CopyOnWriteArrayList<>();
 		interpolationModeListeners = new CopyOnWriteArrayList<>();
 
@@ -339,75 +307,95 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 			}
 		} );
 
+		state.getState().changeListeners().add( this );
+
 		painterThread.start();
 	}
 
+	/**
+	 * @deprecated Modify {@link #state()} directly
+	 */
+	@Deprecated
 	public void addSource( final SourceAndConverter< ? > sourceAndConverter )
 	{
-		synchronized ( visibilityAndGrouping )
-		{
-			state.addSource( sourceAndConverter );
-			visibilityAndGrouping.update( NUM_SOURCES_CHANGED );
-		}
-		requestRepaint();
+		state().addSource( sourceAndConverter );
+		state().setSourceActive( sourceAndConverter, true );
 	}
 
+	/**
+	 * @deprecated Modify {@link #state()} directly
+	 */
+	@Deprecated
 	public void addSources( final Collection< SourceAndConverter< ? > > sourceAndConverter )
 	{
-		synchronized ( visibilityAndGrouping )
-		{
-			sourceAndConverter.forEach( state::addSource );
-			visibilityAndGrouping.update( NUM_SOURCES_CHANGED );
-		}
-		requestRepaint();
+		state().addSources( sourceAndConverter );
+	}
+
+	// helper for deprecated methods taking Source<?>
+	@Deprecated
+	private SourceAndConverter< ? > soc( final Source< ? > source )
+	{
+		for ( final SourceAndConverter< ? > soc : state().getSources() )
+			if ( soc.getSpimSource() == source )
+				return soc;
+		return null;
 	}
 
+	/**
+	 * @deprecated Modify {@link #state()} directly
+	 */
+	@Deprecated
 	public void removeSource( final Source< ? > source )
 	{
-		synchronized ( visibilityAndGrouping )
+		synchronized ( state() )
 		{
-			state.removeSource( source );
-			visibilityAndGrouping.update( NUM_SOURCES_CHANGED );
+			state().removeSource( soc( source ) );
 		}
-		requestRepaint();
 	}
 
+	/**
+	 * @deprecated Modify {@link #state()} directly
+	 */
+	@Deprecated
 	public void removeSources( final Collection< Source< ? > > sources )
 	{
-		synchronized ( visibilityAndGrouping )
+		synchronized ( state() )
 		{
-			sources.forEach( state::removeSource );
-			visibilityAndGrouping.update( NUM_SOURCES_CHANGED );
+			state().removeSources( sources.stream().map( this::soc ).collect( Collectors.toList() ) );
 		}
-		requestRepaint();
 	}
 
+	/**
+	 * @deprecated Modify {@link #state()} directly
+	 */
+	@Deprecated
 	public void removeAllSources()
 	{
-		synchronized ( visibilityAndGrouping )
-		{
-			removeSources( getState().getSources().stream().map( SourceAndConverter::getSpimSource ).collect( Collectors.toList() ) );
-		}
+		state().clearSources();
 	}
 
+	/**
+	 * @deprecated Modify {@link #state()} directly
+	 */
+	@Deprecated
 	public void addGroup( final SourceGroup group )
 	{
-		synchronized ( visibilityAndGrouping )
+		synchronized ( state() )
 		{
 			state.addGroup( group );
-			visibilityAndGrouping.update( NUM_GROUPS_CHANGED );
 		}
-		requestRepaint();
 	}
 
+	/**
+	 * @deprecated Modify {@link #state()} directly
+	 */
+	@Deprecated
 	public void removeGroup( final SourceGroup group )
 	{
-		synchronized ( visibilityAndGrouping )
+		synchronized ( state() )
 		{
 			state.removeGroup( group );
-			visibilityAndGrouping.update( NUM_GROUPS_CHANGED );
 		}
-		requestRepaint();
 	}
 
 	/**
@@ -421,7 +409,7 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 	{
 		assert gPos.length >= 3;
 
-		viewerTransform.applyInverse( gPos, gPos );
+		state().getViewerTransform().applyInverse( gPos, gPos );
 	}
 
 	/**
@@ -435,7 +423,7 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 	{
 		assert gPos.numDimensions() >= 3;
 
-		viewerTransform.applyInverse( gPos, gPos );
+		state().getViewerTransform().applyInverse( gPos, gPos );
 	}
 
 	/**
@@ -451,7 +439,7 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 		final RealPoint lPos = new RealPoint( 3 );
 		lPos.setPosition( x, 0 );
 		lPos.setPosition( y, 1 );
-		viewerTransform.applyInverse( gPos, lPos );
+		state().getViewerTransform().applyInverse( gPos, lPos );
 	}
 
 	/**
@@ -466,7 +454,7 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 		assert gPos.numDimensions() == 3;
 		final RealPoint lPos = new RealPoint( 3 );
 		mouseCoordinates.getMouseCoordinates( lPos );
-		viewerTransform.applyInverse( gPos, lPos );
+		state().getViewerTransform().applyInverse( gPos, lPos );
 	}
 
 	/**
@@ -482,7 +470,7 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 	@Override
 	public void paint()
 	{
-		imageRenderer.paint( state );
+		imageRenderer.paint( state.getState() );
 
 		display.repaint();
 
@@ -490,12 +478,12 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 		{
 			if ( currentAnimator != null )
 			{
-				final TransformEventHandler< AffineTransform3D > handler = display.getTransformEventHandler();
 				final AffineTransform3D transform = currentAnimator.getCurrent( System.currentTimeMillis() );
-				handler.setTransform( transform );
-				transformChanged( transform );
+				state().setViewerTransform( transform );
 				if ( currentAnimator.isComplete() )
 					currentAnimator = null;
+				else
+					requestRepaint();
 			}
 		}
 	}
@@ -509,21 +497,26 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 		imageRenderer.requestRepaint();
 	}
 
+	public void requestRepaint( final Interval screenInterval )
+	{
+		imageRenderer.requestRepaint( screenInterval );
+	}
+
 	@Override
 	public void drawOverlays( final Graphics g )
 	{
 		boolean requiresRepaint = false;
 		if ( Prefs.showMultibox() )
 		{
-			multiBoxOverlayRenderer.setViewerState( state );
+			multiBoxOverlayRenderer.setViewerState( state() );
 			multiBoxOverlayRenderer.updateVirtualScreenSize( display.getWidth(), display.getHeight() );
 			multiBoxOverlayRenderer.paint( ( Graphics2D ) g );
 			requiresRepaint = multiBoxOverlayRenderer.isHighlightInProgress();
 		}
 
-		if( Prefs.showTextOverlay() )
+		if ( Prefs.showTextOverlay() )
 		{
-			sourceInfoOverlayRenderer.setViewerState( state );
+			sourceInfoOverlayRenderer.setViewerState( state() );
 			sourceInfoOverlayRenderer.paint( ( Graphics2D ) g );
 
 			final RealPoint gPos = new RealPoint( 3 );
@@ -537,7 +530,7 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 
 		if ( Prefs.showScaleBar() )
 		{
-			scaleBarOverlayRenderer.setViewerState( state );
+			scaleBarOverlayRenderer.setViewerState( state() );
 			scaleBarOverlayRenderer.paint( ( Graphics2D ) g );
 		}
 
@@ -557,32 +550,25 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 	}
 
 	@Override
-	public synchronized void transformChanged( final AffineTransform3D transform )
+	public void viewerStateChanged( final ViewerStateChange change )
 	{
-		viewerTransform.set( transform );
-		state.setViewerTransform( transform );
-		for ( final TransformListener< AffineTransform3D > l : transformListeners )
-			l.transformChanged( viewerTransform );
-		requestRepaint();
-	}
-
-	@Override
-	public void visibilityChanged( final VisibilityAndGrouping.Event e )
-	{
-		switch ( e.id )
+		switch ( change )
 		{
 		case CURRENT_SOURCE_CHANGED:
-			multiBoxOverlayRenderer.highlight( visibilityAndGrouping.getCurrentSource() );
+			multiBoxOverlayRenderer.highlight( state().getSources().indexOf( state().getCurrentSource() ) );
 			display.repaint();
 			break;
 		case DISPLAY_MODE_CHANGED:
-			showMessage( visibilityAndGrouping.getDisplayMode().getName() );
+			showMessage( state().getDisplayMode().getName() );
 			display.repaint();
 			break;
 		case GROUP_NAME_CHANGED:
 			display.repaint();
 			break;
-		case SOURCE_ACTVITY_CHANGED:
+		case CURRENT_GROUP_CHANGED:
+			// TODO multiBoxOverlayRenderer.highlight() all sources in group that became current
+			break;
+		case SOURCE_ACTIVITY_CHANGED:
 			// TODO multiBoxOverlayRenderer.highlight() all sources that became visible
 			break;
 		case GROUP_ACTIVITY_CHANGED:
@@ -591,6 +577,50 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 		case VISIBILITY_CHANGED:
 			requestRepaint();
 			break;
+//		case SOURCE_TO_GROUP_ASSIGNMENT_CHANGED:
+//		case NUM_SOURCES_CHANGED:
+//		case NUM_GROUPS_CHANGED:
+		case INTERPOLATION_CHANGED:
+			final Interpolation interpolation = state().getInterpolation();
+			showMessage( interpolation.getName() );
+			for ( final InterpolationModeListener l : interpolationModeListeners )
+				l.interpolationModeChanged( interpolation );
+			requestRepaint();
+			break;
+		case NUM_TIMEPOINTS_CHANGED:
+		{
+			final int numTimepoints = state().getNumTimepoints();
+			final int timepoint = Math.max( 0, Math.min( state.getCurrentTimepoint(), numTimepoints - 1 ) );
+			SwingUtilities.invokeLater( () -> {
+				final boolean sliderVisible = Arrays.asList( getComponents() ).contains( sliderTime );
+				if ( numTimepoints > 1 && !sliderVisible )
+					add( sliderTime, BorderLayout.SOUTH );
+				else if ( numTimepoints == 1 && sliderVisible )
+					remove( sliderTime );
+				sliderTime.setModel( new DefaultBoundedRangeModel( timepoint, 0, 0, numTimepoints - 1 ) );
+				revalidate();
+			} );
+			break;
+		}
+		case CURRENT_TIMEPOINT_CHANGED:
+		{
+			final int timepoint = state().getCurrentTimepoint();
+			SwingUtilities.invokeLater( () -> {
+				blockSliderTimeEvents = true;
+				if ( sliderTime.getValue() != timepoint )
+					sliderTime.setValue( timepoint );
+				blockSliderTimeEvents = false;
+			} );
+			for ( final TimePointListener l : timePointListeners )
+				l.timePointChanged( timepoint );
+			requestRepaint();
+			break;
+		}
+		case VIEWER_TRANSFORM_CHANGED:
+			final AffineTransform3D transform = state().getViewerTransform();
+			for ( final TransformListener< AffineTransform3D > l : transformListeners )
+				l.transformChanged( transform );
+			requestRepaint();
 		}
 	}
 
@@ -600,7 +630,7 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 	 * The planes which can be aligned with the viewer coordinate system: XY,
 	 * ZY, and XZ plane.
 	 */
-	public static enum AlignPlane
+	public enum AlignPlane
 	{
 		XY( "XY", 2, new double[] { 1, 0, 0, 0 } ),
 		ZY( "ZY", 0, new double[] { c, 0, -c, 0 } ),
@@ -643,9 +673,9 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 	 */
 	protected synchronized void align( final AlignPlane plane )
 	{
-		final SourceState< ? > source = state.getSources().get( state.getCurrentSource() );
+		final Source< ? > source = state().getCurrentSource().getSpimSource();
 		final AffineTransform3D sourceTransform = new AffineTransform3D();
-		source.getSpimSource().getSourceTransform( state.getCurrentTimepoint(), 0, sourceTransform );
+		source.getSourceTransform( state.getCurrentTimepoint(), 0, sourceTransform );
 
 		final double[] qSource = new double[ 4 ];
 		Affine3DHelpers.extractRotationAnisotropic( sourceTransform, qSource );
@@ -657,7 +687,7 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 		final double[] qTarget = new double[ 4 ];
 		LinAlgHelpers.quaternionInvert( qTmpSource, qTarget );
 
-		final AffineTransform3D transform = display.getTransformEventHandler().getTransform();
+		final AffineTransform3D transform = state().getViewerTransform();
 		double centerX;
 		double centerY;
 		if ( mouseCoordinates.isMouseInsidePanel() )
@@ -672,7 +702,7 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 		}
 		currentAnimator = new RotationAnimator( transform, centerX, centerY, qTarget, 300 );
 		currentAnimator.setTime( System.currentTimeMillis() );
-		transformChanged( transform );
+		requestRepaint();
 	}
 
 	public synchronized void setTransformAnimator( final AbstractTransformAnimator animator )
@@ -686,45 +716,37 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 	 * Switch to next interpolation mode. (Currently, there are two
 	 * interpolation modes: nearest-neighbor and N-linear.)
 	 */
+	// TODO: Deprecate or leave as convenience?
 	public synchronized void toggleInterpolation()
 	{
-		final int i = state.getInterpolation().ordinal();
-		final int n = Interpolation.values().length;
-		final Interpolation mode = Interpolation.values()[ ( i + 1 ) % n ];
-		setInterpolation( mode );
+		state().setInterpolation( state().getInterpolation().next() );
 	}
 
 	/**
 	 * Set the {@link Interpolation} mode.
 	 */
+	// TODO: Deprecate or leave as convenience?
 	public synchronized void setInterpolation( final Interpolation mode )
 	{
-		final Interpolation interpolation = state.getInterpolation();
-		if ( mode != interpolation )
-		{
-			state.setInterpolation( mode );
-			showMessage( mode.getName() );
-			for ( final InterpolationModeListener l : interpolationModeListeners )
-				l.interpolationModeChanged( state.getInterpolation() );
-			requestRepaint();
-		}
+		state().setInterpolation( mode );
 	}
 
 	/**
 	 * Set the {@link DisplayMode}.
 	 */
+	// TODO: Deprecate or leave as convenience?
 	public synchronized void setDisplayMode( final DisplayMode displayMode )
 	{
-		visibilityAndGrouping.setDisplayMode( displayMode );
+		state().setDisplayMode( displayMode );
 	}
 
 	/**
-	 * Set the viewer transform.
+	 * @deprecated Modify {@link #state()} directly ({@code state().setViewerTransform(t)})
 	 */
-	public synchronized void setCurrentViewerTransform( final AffineTransform3D viewerTransform )
+	@Deprecated
+	public void setCurrentViewerTransform( final AffineTransform3D viewerTransform )
 	{
-		display.getTransformEventHandler().setTransform( viewerTransform );
-		transformChanged( viewerTransform );
+		state().setViewerTransform( viewerTransform );
 	}
 
 	/**
@@ -733,34 +755,40 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 	 * @param timepoint
 	 *            time-point index.
 	 */
+	// TODO: Deprecate or leave as convenience?
 	public synchronized void setTimepoint( final int timepoint )
 	{
-		if ( state.getCurrentTimepoint() != timepoint )
-		{
-			state.setCurrentTimepoint( timepoint );
-			sliderTime.setValue( timepoint );
-			for ( final TimePointListener l : timePointListeners )
-				l.timePointChanged( timepoint );
-			requestRepaint();
-		}
+		state().setCurrentTimepoint( timepoint );
 	}
 
 	/**
 	 * Show the next time-point.
 	 */
+	// TODO: Deprecate or leave as convenience?
 	public synchronized void nextTimePoint()
 	{
-		if ( state.getNumTimepoints() > 1 )
-			sliderTime.setValue( sliderTime.getValue() + 1 );
+		final SynchronizedViewerState state = state();
+		synchronized ( state )
+		{
+			final int t = state.getCurrentTimepoint() + 1;
+			if ( t < state.getNumTimepoints() )
+				state.setCurrentTimepoint( t );
+		}
 	}
 
 	/**
 	 * Show the previous time-point.
 	 */
+	// TODO: Deprecate or leave as convenience?
 	public synchronized void previousTimePoint()
 	{
-		if ( state.getNumTimepoints() > 1 )
-			sliderTime.setValue( sliderTime.getValue() - 1 );
+		final SynchronizedViewerState state = state();
+		synchronized ( state )
+		{
+			final int t = state.getCurrentTimepoint() - 1;
+			if ( t >= 0 )
+				state.setCurrentTimepoint( t );
+		}
 	}
 
 	/**
@@ -768,64 +796,55 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 	 * this will hide the time slider, otherwise show it. If the currently
 	 * displayed timepoint would be out of range with the new number of
 	 * timepoints, the current timepoint is set to {@code numTimepoints - 1}.
+	 * <p>
+	 * This is equivalent to {@code state().setNumTimepoints(numTimepoints}}.
 	 *
 	 * @param numTimepoints
 	 *            number of available timepoints. Must be {@code >= 1}.
 	 */
 	public void setNumTimepoints( final int numTimepoints )
 	{
-		try
-		{
-			InvokeOnEDT.invokeAndWait( () -> setNumTimepointsSynchronized( numTimepoints ) );
-		}
-		catch ( InvocationTargetException | InterruptedException e )
-		{
-			e.printStackTrace();
-		}
-	}
-
-	private synchronized void setNumTimepointsSynchronized( final int numTimepoints )
-	{
-		if ( numTimepoints < 1 || state.getNumTimepoints() == numTimepoints )
-			return;
-		else if ( numTimepoints == 1 && state.getNumTimepoints() > 1 )
-			remove( sliderTime );
-		else if ( numTimepoints > 1 && state.getNumTimepoints() == 1 )
-			add( sliderTime, BorderLayout.SOUTH );
-
 		state.setNumTimepoints( numTimepoints );
-		if ( state.getCurrentTimepoint() >= numTimepoints )
-		{
-			final int timepoint = numTimepoints - 1;
-			state.setCurrentTimepoint( timepoint );
-			for ( final TimePointListener l : timePointListeners )
-				l.timePointChanged( timepoint );
-		}
-		sliderTime.setModel( new DefaultBoundedRangeModel( state.getCurrentTimepoint(), 0, 0, numTimepoints - 1 ) );
-		revalidate();
-		requestRepaint();
 	}
 
 	/**
+	 * @deprecated Use {@link #state()} instead.
+	 *
 	 * Get a copy of the current {@link ViewerState}.
 	 *
 	 * @return a copy of the current {@link ViewerState}.
 	 */
+	@Deprecated
 	public ViewerState getState()
 	{
 		return state.copy();
 	}
 
+	/**
+	 * Get the ViewerState. This can be directly used for modifications, e.g.,
+	 * adding/removing sources etc. See {@link SynchronizedViewerState} for
+	 * thread-safety considerations.
+	 */
+	public SynchronizedViewerState state()
+	{
+		return state.getState();
+	}
+
 	/**
 	 * Get the viewer canvas.
 	 *
 	 * @return the viewer canvas.
 	 */
-	public InteractiveDisplayCanvasComponent< AffineTransform3D > getDisplay()
+	public InteractiveDisplayCanvas getDisplay()
 	{
 		return display;
 	}
 
+	public TransformEventHandler getTransformEventHandler()
+	{
+		return transformEventHandler;
+	}
+
 	/**
 	 * Display the specified message in a text overlay for a short time.
 	 *
@@ -841,7 +860,7 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 	/**
 	 * Add a new {@link OverlayAnimator} to the list of animators. The animation
 	 * is immediately started. The new {@link OverlayAnimator} will remain in
-	 * the list of animators until it {@link OverlayAnimator#isComplete()}.å
+	 * the list of animators until it {@link OverlayAnimator#isComplete()}.
 	 *
 	 * @param animator
 	 *            animator to add.
@@ -877,37 +896,34 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 	}
 
 	/**
-	 * Add a {@link TransformListener} to notify about viewer transformation
+	 * Add/remove {@code TransformListener}s to notify about viewer transformation
 	 * changes. Listeners will be notified when a new image has been painted
 	 * with the viewer transform used to render that image.
-	 *
+	 * <p>
 	 * This happens immediately after that image is painted onto the screen,
 	 * before any overlays are painted.
-	 *
-	 * @param listener
-	 *            the transform listener to add.
 	 */
+	public Listeners< TransformListener< AffineTransform3D > > renderTransformListeners()
+	{
+		return renderTarget.transformListeners();
+	}
+
+	/**
+	 * @deprecated Use {@code renderTransformListeners().add( listener )}.
+	 */
+	@Deprecated
 	public void addRenderTransformListener( final TransformListener< AffineTransform3D > listener )
 	{
-		renderTarget.addTransformListener( listener );
+		renderTransformListeners().add( listener );
 	}
 
 	/**
-	 * Add a {@link TransformListener} to notify about viewer transformation
-	 * changes. Listeners will be notified when a new image has been painted
-	 * with the viewer transform used to render that image.
-	 *
-	 * This happens immediately after that image is painted onto the screen,
-	 * before any overlays are painted.
-	 *
-	 * @param listener
-	 *            the transform listener to add.
-	 * @param index
-	 *            position in the list of listeners at which to insert this one.
+	 * @deprecated Use {@code renderTransformListeners().add( index, listener )}.
 	 */
+	@Deprecated
 	public void addRenderTransformListener( final TransformListener< AffineTransform3D > listener, final int index )
 	{
-		renderTarget.addTransformListener( listener, index );
+		renderTransformListeners().add( index, listener );
 	}
 
 	/**
@@ -939,7 +955,7 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 		{
 			final int s = transformListeners.size();
 			transformListeners.add( index < 0 ? 0 : index > s ? s : index, listener );
-			listener.transformChanged( viewerTransform );
+			listener.transformChanged( state().getViewerTransform() );
 		}
 	}
 
@@ -955,7 +971,7 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 		{
 			transformListeners.remove( listener );
 		}
-		renderTarget.removeTransformListener( listener );
+		renderTarget.transformListeners().remove( listener );
 	}
 
 	/**
@@ -1093,10 +1109,13 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 	{}
 
 	/**
+	 * @deprecated Modify {@link #state()} directly
+	 *
 	 * Returns the {@link VisibilityAndGrouping} that can be used to modify
 	 * visibility and currentness of sources and groups, as well as grouping of
 	 * sources, and display mode.
 	 */
+	@Deprecated
 	public VisibilityAndGrouping getVisibilityAndGrouping()
 	{
 		return visibilityAndGrouping;
@@ -1129,18 +1148,26 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 		renderingExecutorService.shutdown();
 		state.kill();
 		imageRenderer.kill();
+		renderTarget.kill();
 	}
 
 	protected static final AtomicInteger panelNumber = new AtomicInteger( 1 );
 
-	protected class RenderThreadFactory implements ThreadFactory
+	protected static class RenderThreadFactory implements ThreadFactory
 	{
+		private final ThreadGroup threadGroup;
+
 		private final String threadNameFormat = String.format(
 				"bdv-panel-%d-thread-%%d",
 				panelNumber.getAndIncrement() );
 
 		private final AtomicInteger threadNumber = new AtomicInteger( 1 );
 
+		protected RenderThreadFactory( final ThreadGroup threadGroup )
+		{
+			this.threadGroup = threadGroup;
+		}
+
 		@Override
 		public Thread newThread( final Runnable r )
 		{
@@ -1155,9 +1182,9 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 		}
 	}
 
-	public TransformAwareBufferedImageOverlayRenderer renderTarget()
+	@Override
+	public boolean requestFocusInWindow()
 	{
-		return this.renderTarget;
+		return display.requestFocusInWindow();
 	}
-
 }
diff --git a/src/main/java/bdv/viewer/ViewerState.java b/src/main/java/bdv/viewer/ViewerState.java
new file mode 100644
index 0000000000000000000000000000000000000000..c8766a3410ed75be10d1fef48b4b3ae30c62cef9
--- /dev/null
+++ b/src/main/java/bdv/viewer/ViewerState.java
@@ -0,0 +1,790 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.viewer;
+
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Set;
+import net.imglib2.realtransform.AffineTransform3D;
+import org.scijava.listeners.Listeners;
+
+/**
+ * Reading and writing the BigDataViewer state:
+ * <ul>
+ * <li>interpolation and display mode</li>
+ * <li>contained sources and source groups</li>
+ * <li>activeness / currentness of sources and groups</li>
+ * <li>current timepoint and transformation</li>
+ * </ul>
+ *
+ * @author Tobias Pietzsch
+ */
+public interface ViewerState
+{
+	/**
+	 * Get a snapshot of this ViewerState.
+	 *
+	 * @return unmodifiable copy of the current state
+	 */
+	ViewerState snapshot();
+
+	/**
+	 * {@code ViewerStateChangeListener}s can be added/removed here,
+	 * and will be notified about changes to this ViewerState.
+	 */
+	Listeners< ViewerStateChangeListener > changeListeners();
+
+	/**
+	 * Get the interpolation method.
+	 *
+	 * @return interpolation method
+	 */
+	Interpolation getInterpolation();
+
+	/**
+	 * Set the interpolation method (optional operation).
+	 *
+	 * @throws UnsupportedOperationException
+	 *     if the operation is not supported by this ViewerState
+	 */
+	void setInterpolation( Interpolation interpolation );
+
+	/**
+	 * Get the current {@code DisplayMode}.
+	 * <ul>
+	 * <li>In {@code DisplayMode.SINGLE} only the current source is visible.</li>
+	 * <li>In {@code DisplayMode.GROUP} the sources in the current group are visible.</li>
+	 * <li>In {@code DisplayMode.FUSED} all active sources are visible.</li>
+	 * <li>In {@code DisplayMode.FUSEDROUP} the sources in all active groups are visible.</li>
+	 * </ul>
+	 *
+	 * @return the current display mode
+	 */
+	DisplayMode getDisplayMode();
+
+	/**
+	 * Set the {@link DisplayMode} (optional operation).
+	 *
+	 * @throws UnsupportedOperationException
+	 *     if the operation is not supported by this ViewerState
+	 */
+	void setDisplayMode( DisplayMode mode );
+
+	/**
+	 * Get the number of timepoints.
+	 *
+	 * @return the number of timepoints
+	 */
+	int getNumTimepoints();
+
+	/**
+	 * Set the number of timepoints (optional operation).
+	 * <p>
+	 * If {@link #getCurrentTimepoint()} current timepoint} is
+	 * {@code >= n}, it will be adjusted to {@code n-1}.
+	 *
+	 * @throws IllegalArgumentException
+	 *     if {@code n < 1}.
+	 * @throws UnsupportedOperationException
+	 *     if the operation is not supported by this ViewerState
+	 */
+	void setNumTimepoints( int n );
+
+	/**
+	 * Get the current timepoint.
+	 *
+	 * @return current timepoint (index)
+	 */
+	int getCurrentTimepoint();
+
+	/**
+	 * Set the current timepoint (optional operation).
+	 *
+	 * @throws IllegalArgumentException
+	 *     if {@code t >= getNumTimepoints()} or {@code t < 0}.
+	 * @throws UnsupportedOperationException
+	 *     if the operation is not supported by this ViewerState
+	 */
+	void setCurrentTimepoint( int t );
+
+	/**
+	 * Get the viewer transform.
+	 *
+	 * @param transform
+	 *     is set to the viewer transform
+	 */
+	void getViewerTransform( AffineTransform3D transform );
+
+	/**
+	 * Get the viewer transform.
+	 *
+	 * @return a copy of the current viewer transform
+	 */
+	default AffineTransform3D getViewerTransform()
+	{
+		final AffineTransform3D transform = new AffineTransform3D();
+		getViewerTransform( transform );
+		return transform;
+	}
+
+	/**
+	 * Set the viewer transform (optional operation).
+	 *
+	 * @throws UnsupportedOperationException
+	 *     if the operation is not supported by this ViewerState
+	 */
+	void setViewerTransform( AffineTransform3D transform );
+
+	// --------------------
+	// --    sources     --
+	// --------------------
+
+	/**
+	 * Get the list of sources. The returned {@code List} reflects changes to
+	 * the viewer state. It is unmodifiable and not thread-safe.
+	 *
+	 * @return the list of sources
+	 */
+	List< SourceAndConverter< ? > > getSources();
+
+	/**
+	 * Get the current source. (May return {@code null} if there is no current
+	 * source)
+	 *
+	 * @return the current source
+	 */
+	SourceAndConverter< ? > getCurrentSource();
+
+	/**
+	 * Returns {@code true} if {@code source} is the current source. Equivalent
+	 * to {@code (getCurrentSource() == source)}.
+	 *
+	 * @param source
+	 *     the source. Passing {@code null} checks whether no source is current.
+	 * @return {@code true} if {@code source} is the current source
+	 */
+	boolean isCurrentSource( SourceAndConverter< ? > source );
+
+	/**
+	 * Make {@code source} the current source (optional operation). Returns
+	 * {@code true}, if current source changes as a result of the call. Returns
+	 * {@code false}, if {@code source} is already the current source.
+	 *
+	 * @param source
+	 *     the source to make current. Passing {@code null} clears the current
+	 *     source.
+	 *
+	 * @return {@code true}, if current source changed as a result of the call
+	 *
+	 * @throws IllegalArgumentException
+	 *     if {@code source} is not contained in the state (and not
+	 *     {@code null}).
+	 * @throws UnsupportedOperationException
+	 *     if the operation is not supported by this ViewerState
+	 */
+	boolean setCurrentSource( SourceAndConverter< ? > source );
+
+	/**
+	 * Get the set of active sources. The returned {@code Set} reflects changes
+	 * to the viewer state. It is unmodifiable and not thread-safe.
+	 *
+	 * @return the set of active sources
+	 */
+	Set< SourceAndConverter< ? > > getActiveSources();
+
+	/**
+	 * Check whether the given {@code source} is active.
+	 *
+	 * @return {@code true}, if {@code source} is active
+	 *
+	 * @throws NullPointerException
+	 *     if {@code source == null}
+	 * @throws IllegalArgumentException
+	 *     if {@code source} is not contained in the state (and not
+	 *     {@code null}).
+	 */
+	boolean isSourceActive( SourceAndConverter< ? > source );
+
+	/**
+	 * Set {@code source} active or inactive (optional operation).
+	 * <p>
+	 * Returns {@code true}, if source activity changes as a result of the call.
+	 * Returns {@code false}, if {@code source} is already in the desired
+	 * {@code active} state.
+	 *
+	 * @return {@code true}, if source activity changed as a result of the call
+	 *
+	 * @throws NullPointerException
+	 *     if {@code source == null}
+	 * @throws IllegalArgumentException
+	 *     if {@code source} is not contained in the state (and not
+	 *     {@code null}).
+	 * @throws UnsupportedOperationException
+	 *     if the operation is not supported by this ViewerState
+	 */
+	boolean setSourceActive( SourceAndConverter< ? > source, boolean active );
+
+	/**
+	 * Set all sources in {@code collection} active or inactive (optional
+	 * operation).
+	 * <p>
+	 * Returns {@code true}, if source activity changes as a result of the call.
+	 * Returns {@code false}, if all sources were already in the desired
+	 * {@code active} state.
+	 *
+	 * @return {@code true}, if source activity changed as a result of the call
+	 *
+	 * @throws NullPointerException
+	 *     if {@code collection == null} or any element of {@code collection} is
+	 *     {@code null}.
+	 * @throws IllegalArgumentException
+	 *     if any element of {@code collection} is not contained in the state.
+	 * @throws UnsupportedOperationException
+	 *     if the operation is not supported by this ViewerState
+	 */
+	boolean setSourcesActive( Collection< ? extends SourceAndConverter< ? > > collection, boolean active );
+
+	/**
+	 * Check whether the given {@code source} is visible.
+	 * <p>
+	 * Whether a source is visible depends on the {@link #getDisplayMode()
+	 * display mode}:
+	 * <ul>
+	 * <li>In {@code DisplayMode.SINGLE} only the current source is visible.</li>
+	 * <li>In {@code DisplayMode.GROUP} the sources in the current group are visible.</li>
+	 * <li>In {@code DisplayMode.FUSED} all active sources are visible.</li>
+	 * <li>In {@code DisplayMode.FUSEDROUP} the sources in all active groups are visible.</li>
+	 * </ul>
+	 *
+	 * @return {@code true}, if {@code source} is visible
+	 *
+	 * @throws NullPointerException
+	 *     if {@code source == null}
+	 * @throws IllegalArgumentException
+	 *     if {@code source} is not contained in the state (and not
+	 *     {@code null}).
+	 */
+	boolean isSourceVisible( SourceAndConverter< ? > source );
+
+	/**
+	 * Check whether the given {@code source} is both visible and provides image
+	 * data for the current timepoint.
+	 * <p>
+	 * Whether a source is visible depends on the {@link #getDisplayMode()
+	 * display mode}:
+	 * <ul>
+	 * <li>In {@code DisplayMode.SINGLE} only the current source is visible.</li>
+	 * <li>In {@code DisplayMode.GROUP} the sources in the current group are visible.</li>
+	 * <li>In {@code DisplayMode.FUSED} all active sources are visible.</li>
+	 * <li>In {@code DisplayMode.FUSEDROUP} the sources in all active groups are visible.</li>
+	 * </ul>
+	 * Additionally, the source must be {@link bdv.viewer.Source#isPresent(int)
+	 * present}, i.e., provide image data for the {@link #getCurrentTimepoint()
+	 * current timepoint}.
+	 *
+	 * @return {@code true}, if {@code source} is both visible and present
+	 *
+	 * @throws NullPointerException
+	 *     if {@code source == null}
+	 * @throws IllegalArgumentException
+	 *     if {@code source} is not contained in the state (and not
+	 *     {@code null}).
+	 */
+	boolean isSourceVisibleAndPresent( SourceAndConverter< ? > source );
+
+	/**
+	 * Get the set of visible sources.
+	 * <p>
+	 * The returned {@code Set} is a copy. Changes to the set will not be
+	 * reflected in the viewer state, and vice versa.
+	 * <p>
+	 * Whether a source is visible depends on the {@link #getDisplayMode()
+	 * display mode}:
+	 * <ul>
+	 * <li>In {@code DisplayMode.SINGLE} only the current source is visible.</li>
+	 * <li>In {@code DisplayMode.GROUP} the sources in the current group are visible.</li>
+	 * <li>In {@code DisplayMode.FUSED} all active sources are visible.</li>
+	 * <li>In {@code DisplayMode.FUSEDROUP} the sources in all active groups are visible.</li>
+	 * </ul>
+	 *
+	 * @return the set of visible sources
+	 */
+	Set< SourceAndConverter< ? > > getVisibleSources();
+
+	/**
+	 * Get the set of visible sources that also provide image data for the
+	 * current timepoint.
+	 * <p>
+	 * The returned {@code Set} is a copy. Changes to the set will not be
+	 * reflected in the viewer state, and vice versa.
+	 * <p>
+	 * Whether a source is visible depends on the {@link #getDisplayMode()
+	 * display mode}:
+	 * <ul>
+	 * <li>In {@code DisplayMode.SINGLE} only the current source is visible.</li>
+	 * <li>In {@code DisplayMode.GROUP} the sources in the current group are visible.</li>
+	 * <li>In {@code DisplayMode.FUSED} all active sources are visible.</li>
+	 * <li>In {@code DisplayMode.FUSEDROUP} the sources in all active groups are visible.</li>
+	 * </ul>
+	 * Additionally, the source must be {@link bdv.viewer.Source#isPresent(int)
+	 * present}, i.e., provide image data for the {@link #getCurrentTimepoint()
+	 * current timepoint}.
+	 *
+	 * @return the set of sources that are both visible and present
+	 */
+	Set< SourceAndConverter< ? > > getVisibleAndPresentSources();
+
+	/**
+	 * Check whether the state contains the {@code source}.
+	 *
+	 * @return {@code true}, if {@code source} is in the list of sources.
+	 *
+	 * @throws NullPointerException
+	 *     if {@code source == null}
+	 */
+	boolean containsSource( SourceAndConverter< ? > source );
+
+	/**
+	 * Add {@code source} to the state (optional operation). Returns
+	 * {@code true}, if the source is added. Returns {@code false}, if the
+	 * source is already present.
+	 * <p>
+	 * If {@code source} is added and no other source was current, then
+	 * {@code source} is made current
+	 *
+	 * @return {@code true}, if list of sources changed as a result of the call.
+	 *
+	 * @throws NullPointerException
+	 *     if {@code source == null}
+	 * @throws UnsupportedOperationException
+	 *     if the operation is not supported by this ViewerState
+	 */
+	boolean addSource( SourceAndConverter< ? > source );
+
+	/**
+	 * Add all sources in {@code collection} to the state (optional operation).
+	 * Returns {@code true}, if at least one source was added. Returns
+	 * {@code false}, if all sources were already present.
+	 * <p>
+	 * If any sources are added and no other source was current, then the first
+	 * added sources will be made current.
+	 *
+	 * @return {@code true}, if list of sources changed as a result of the call.
+	 *
+	 * @throws NullPointerException
+	 *     if {@code collection == null} or any element of {@code collection} is
+	 *     {@code null}.
+	 * @throws UnsupportedOperationException
+	 *     if the operation is not supported by this ViewerState
+	 */
+	boolean addSources( Collection< ? extends SourceAndConverter< ? > > collection );
+
+	/**
+	 * Remove {@code source} from the state (optional operation).
+	 * <p>
+	 * Returns {@code true}, if {@code source} was removed from the state.
+	 * Returns {@code false}, if {@code source} was not contained in state.
+	 * <p>
+	 * The {@code source} is also removed from any groups that contained it. If
+	 * {@code source} was current, then the first source in the list of sources
+	 * is made current (if it exists).
+	 *
+	 * @return {@code true}, if list of sources changed as a result of the call
+	 *
+	 * @throws NullPointerException
+	 *     if {@code source == null}
+	 * @throws UnsupportedOperationException
+	 *     if the operation is not supported by this ViewerState
+	 */
+	boolean removeSource( SourceAndConverter< ? > source );
+
+	/**
+	 * Remove all sources in {@code collection} from the state (optional
+	 * operation). Returns {@code true}, if at least one source was removed.
+	 * Returns {@code false}, if none of the sources was present.
+	 * <p>
+	 * Removed sources are also removed from any groups containing them. If the
+	 * current source was removed, then the first source in the remaining list
+	 * of sources is made current (if it exists).
+	 *
+	 * @return {@code true}, if list of sources changed as a result of the call.
+	 *
+	 * @throws NullPointerException
+	 *     if {@code collection == null} or any element of {@code collection} is
+	 *     {@code null}.
+	 * @throws UnsupportedOperationException
+	 *     if the operation is not supported by this ViewerState
+	 */
+	boolean removeSources( Collection< ? extends SourceAndConverter< ? > > collection );
+
+	// TODO addSource with index
+	// TODO addSources with index
+
+	/**
+	 * Remove all sources from the state (optional operation).
+	 *
+	 * @throws UnsupportedOperationException
+	 *     if the operation is not supported by this ViewerState
+	 */
+	void clearSources();
+
+	/**
+	 * Returns a {@link Comparator} that compares sources according to the order
+	 * in which they occur in the sources list. (Sources that do not occur in
+	 * the list are ordered before any source in the list).
+	 */
+	Comparator< SourceAndConverter< ? > > sourceOrder();
+
+	// --------------------
+	// --     groups     --
+	// --------------------
+
+	/**
+	 * Get the list of groups. The returned {@code List} reflects changes to the
+	 * viewer state. It is unmodifiable and not thread-safe.
+	 *
+	 * @return the list of groups
+	 */
+	List< SourceGroup > getGroups();
+
+	/**
+	 * Get the current group. (May return {@code null} if there is no current
+	 * group)
+	 *
+	 * @return the current group
+	 */
+	SourceGroup getCurrentGroup();
+
+	/**
+	 * Returns {@code true} if {@code group} is the current group. Equivalent to
+	 * {@code (getCurrentGroup() == group)}.
+	 *
+	 * @return {@code true} if {@code group} is the current group
+	 */
+	boolean isCurrentGroup( SourceGroup group );
+
+	/**
+	 * Make {@code group} the current group (optional operation). Returns
+	 * {@code true}, if current group changes as a result of the call. Returns
+	 * {@code false}, if {@code group} is already the current group.
+	 *
+	 * @param group
+	 *     the group to make current
+	 * @return {@code true}, if current group changed as a result of the call.
+	 *
+	 * @throws IllegalArgumentException
+	 *     if {@code group} is not contained in the state (and not
+	 *     {@code null}).
+	 * @throws UnsupportedOperationException
+	 *     if the operation is not supported by this ViewerState
+	 */
+	boolean setCurrentGroup( SourceGroup group );
+
+	/**
+	 * Get the set of active groups. The returned {@code Set} reflects changes
+	 * to the viewer state. It is unmodifiable and not thread-safe.
+	 *
+	 * @return the set of active groups
+	 */
+	Set< SourceGroup > getActiveGroups();
+
+	/**
+	 * Check whether the given {@code group} is active.
+	 *
+	 * @return {@code true}, if {@code group} is active
+	 *
+	 * @throws NullPointerException
+	 *     if {@code group == null}
+	 * @throws IllegalArgumentException
+	 *     if {@code group} is not contained in the state (and not
+	 *     {@code null}).
+	 */
+	boolean isGroupActive( SourceGroup group );
+
+	/**
+	 * Set {@code group} active or inactive (optional operation).
+	 * <p>
+	 * Returns {@code true}, if group activity changes as a result of the call.
+	 * Returns {@code false}, if {@code group} is already in the desired
+	 * {@code active} state.
+	 *
+	 * @return {@code true}, if group activity changed as a result of the call
+	 *
+	 * @throws NullPointerException
+	 *     if {@code group == null}
+	 * @throws IllegalArgumentException
+	 *     if {@code group} is not contained in the state (and not
+	 *     {@code null}).
+	 * @throws UnsupportedOperationException
+	 *     if the operation is not supported by this ViewerState
+	 */
+	boolean setGroupActive( SourceGroup group, boolean active );
+
+	/**
+	 * Set all groups in {@code collection} active or inactive (optional
+	 * operation).
+	 * <p>
+	 * Returns {@code true}, if group activity changes as a result of the call.
+	 * Returns {@code false}, if all groups were already in the desired
+	 * {@code active} state.
+	 *
+	 * @return {@code true}, if group activity changed as a result of the call
+	 *
+	 * @throws NullPointerException
+	 *     if {@code collection == null} or any element of {@code collection} is
+	 *     {@code null}.
+	 * @throws IllegalArgumentException
+	 *     if any element of {@code collection} is not contained in the state.
+	 * @throws UnsupportedOperationException
+	 *     if the operation is not supported by this ViewerState
+	 */
+	boolean setGroupsActive( Collection< ? extends SourceGroup > collection, boolean active );
+
+	/**
+	 * Get the name of a {@code group}.
+	 *
+	 * @return name of the group, may be {@code null}
+	 *
+	 * @throws NullPointerException
+	 *     if {@code group == null}
+	 * @throws IllegalArgumentException
+	 *     if {@code group} is not contained in the state (and not
+	 *     {@code null}).
+	 */
+	String getGroupName( final SourceGroup group );
+
+	/**
+	 * Set the {@code name} of a {@code group} (optional operation).
+	 *
+	 * @throws NullPointerException
+	 *     if {@code group == null}
+	 * @throws IllegalArgumentException
+	 *     if {@code group} is not contained in the state (and not
+	 *     {@code null}).
+	 * @throws UnsupportedOperationException
+	 *     if the operation is not supported by this ViewerState
+	 */
+	void setGroupName( SourceGroup group, String name );
+
+	/**
+	 * Check whether the state contains the {@code group}.
+	 *
+	 * @return {@code true}, if {@code group} is in the list of groups.
+	 *
+	 * @throws NullPointerException
+	 *     if {@code group == null}
+	 */
+	boolean containsGroup( SourceGroup group );
+
+	/**
+	 * Add {@code group} to the state (optional operation). Returns
+	 * {@code true}, if the group is added. Returns {@code false}, if the group
+	 * is already present.
+	 * <p>
+	 * If {@code group} is added and no other group was current, then
+	 * {@code group} is made current
+	 *
+	 * @return {@code true}, if list of groups changed as a result of the call.
+	 *
+	 * @throws NullPointerException
+	 *     if {@code group == null}
+	 * @throws UnsupportedOperationException
+	 *     if the operation is not supported by this ViewerState
+	 */
+	boolean addGroup( SourceGroup group );
+
+	/**
+	 * Add all groups in {@code collection} to the state (optional operation).
+	 * Returns {@code true}, if at least one group was added. Returns
+	 * {@code false}, if all groups were already present.
+	 * <p>
+	 * If any groups are added and no other group was current, then the first
+	 * added groups will be made current.
+	 *
+	 * @return {@code true}, if list of groups changed as a result of the call.
+	 *
+	 * @throws NullPointerException
+	 *     if {@code collection == null} or any element of {@code collection} is
+	 *     {@code null}.
+	 * @throws UnsupportedOperationException
+	 *     if the operation is not supported by this ViewerState
+	 */
+	boolean addGroups( Collection< ? extends SourceGroup > collection );
+
+	/**
+	 * Remove {@code group} from the state (optional operation).
+	 * <p>
+	 * Returns {@code true}, if {@code group} was removed from the state.
+	 * Returns {@code false}, if {@code group} was not contained in state.
+	 * <p>
+	 * If {@code group} was current, then the first group in the list of groups
+	 * is made current (if it exists).
+	 *
+	 * @return {@code true}, if list of groups changed as a result of the call
+	 *
+	 * @throws NullPointerException
+	 *     if {@code group == null}
+	 * @throws UnsupportedOperationException
+	 *     if the operation is not supported by this ViewerState
+	 */
+	boolean removeGroup( SourceGroup group );
+
+	/**
+	 * Remove all groups in {@code collection} from the state (optional
+	 * operation). Returns {@code true}, if at least one group was removed.
+	 * Returns {@code false}, if none of the groups was present.
+	 * <p>
+	 * If the current group was removed, then the first group in the remaining
+	 * list of groups is made current (if it exists).
+	 *
+	 * @return {@code true}, if list of groups changed as a result of the call.
+	 *
+	 * @throws NullPointerException
+	 *     if {@code collection == null} or any element of {@code collection} is
+	 *     {@code null}.
+	 * @throws UnsupportedOperationException
+	 *     if the operation is not supported by this ViewerState
+	 */
+	boolean removeGroups( Collection< ? extends SourceGroup > collection );
+
+	/**
+	 * Add {@code source} to {@code group} (optional operation).
+	 * <p>
+	 * Returns {@code true}, if {@code source} was added to {@code group}.
+	 * Returns {@code false}, if {@code source} was already contained in
+	 * {@code group}. or either of {@code source} and {@code group} is not valid
+	 * (not in the BDV sources/groups list).
+	 *
+	 * @return {@code true}, if set of sources in {@code group} changed as a
+	 * result of the call
+	 *
+	 * @throws NullPointerException
+	 *     if {@code source == null} or {@code group == null}
+	 * @throws IllegalArgumentException
+	 *     if either of {@code source} and {@code group} is not contained in the
+	 *     state (and not {@code null}).
+	 * @throws UnsupportedOperationException
+	 *     if the operation is not supported by this ViewerState
+	 */
+	boolean addSourceToGroup( SourceAndConverter< ? > source, SourceGroup group );
+
+	/**
+	 * Add all sources in {@code collection} to {@code group} (optional
+	 * operation).
+	 * <p>
+	 * Returns {@code true}, if at least one source was added to {@code group}.
+	 * Returns {@code false}, if all sources were already contained in
+	 * {@code group}.
+	 *
+	 * @return {@code true}, if set of sources in {@code group} changed as a
+	 * result of the call
+	 *
+	 * @throws NullPointerException
+	 *     if {@code group == null} or {@code collection == null} or any element
+	 *     of {@code collection} is {@code null}.
+	 * @throws IllegalArgumentException
+	 *     if {@code group} or any element of {@code collection} is is not
+	 *     contained in the state (and not {@code null}).
+	 * @throws UnsupportedOperationException
+	 *     if the operation is not supported by this ViewerState
+	 */
+	boolean addSourcesToGroup( Collection< ? extends SourceAndConverter< ? > > collection, SourceGroup group );
+
+	/**
+	 * Remove {@code source} from {@code group} (optional operation).
+	 * <p>
+	 * Returns {@code true}, if {@code source} was removed from {@code group}.
+	 * Returns {@code false}, if {@code source} was not contained in
+	 * {@code group},
+	 *
+	 * @return {@code true}, if set of sources in {@code group} changed as a
+	 * result of the call
+	 *
+	 * @throws NullPointerException
+	 *     if {@code source == null} or {@code group == null}
+	 * @throws IllegalArgumentException
+	 *     if either of {@code source} and {@code group} is not contained in the
+	 *     state (and not {@code null}).
+	 * @throws UnsupportedOperationException
+	 *     if the operation is not supported by this ViewerState
+	 */
+	boolean removeSourceFromGroup( SourceAndConverter< ? > source, SourceGroup group );
+
+	/**
+	 * Remove all sources in {@code collection} from {@code group} (optional
+	 * operation).
+	 * <p>
+	 * Returns {@code true}, if at least one source was removed from
+	 * {@code group}. Returns {@code false}, if none of the sources were
+	 * contained in {@code group}.
+	 *
+	 * @return {@code true}, if set of sources in {@code group} changed as a
+	 * result of the call
+	 *
+	 * @throws NullPointerException
+	 *     if {@code group == null} or {@code collection == null} or any element
+	 *     of {@code collection} is {@code null}.
+	 * @throws IllegalArgumentException
+	 *     if {@code group} or any element of {@code collection} is is not
+	 *     contained in the state (and not {@code null}).
+	 * @throws UnsupportedOperationException
+	 *     if the operation is not supported by this ViewerState
+	 */
+	boolean removeSourcesFromGroup( Collection< ? extends SourceAndConverter< ? > > collection, SourceGroup group );
+
+	/**
+	 * Get the set sources in {@code group}. The returned {@code Set} reflects
+	 * changes to the viewer state. It is unmodifiable and not thread-safe.
+	 *
+	 * @return the set of sources in {@code group}
+	 *
+	 * @throws NullPointerException
+	 *     if {@code group == null}
+	 * @throws IllegalArgumentException
+	 *     if {@code group} is not contained in the state (and not
+	 *     {@code null}).
+	 */
+	Set< SourceAndConverter< ? > > getSourcesInGroup( SourceGroup group );
+
+	/**
+	 * Remove all groups from the state (optional operation).
+	 *
+	 * @throws UnsupportedOperationException
+	 *     if the operation is not supported by this ViewerState
+	 */
+	void clearGroups();
+
+	/**
+	 * Returns a {@link Comparator} that compares groups according to the order
+	 * in which they occur in the groups list. (Groups that do not occur in the
+	 * list are ordered before any group in the list).
+	 */
+	Comparator< SourceGroup > groupOrder();
+}
diff --git a/src/main/java/bdv/viewer/ViewerStateChange.java b/src/main/java/bdv/viewer/ViewerStateChange.java
new file mode 100644
index 0000000000000000000000000000000000000000..dd1f7def1d573768ab9cf99f03513098bb4c7c2a
--- /dev/null
+++ b/src/main/java/bdv/viewer/ViewerStateChange.java
@@ -0,0 +1,53 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.viewer;
+
+/**
+ * Types of BigDataViewer state changes that {@link ViewerStateChangeListener}s
+ * can be notified about.
+ *
+ * @author Tobias Pietzsch
+ */
+public enum ViewerStateChange
+{
+	CURRENT_SOURCE_CHANGED,
+	CURRENT_GROUP_CHANGED,
+	SOURCE_ACTIVITY_CHANGED,
+	GROUP_ACTIVITY_CHANGED,
+	SOURCE_TO_GROUP_ASSIGNMENT_CHANGED,
+	GROUP_NAME_CHANGED,
+	NUM_SOURCES_CHANGED,
+	NUM_GROUPS_CHANGED,
+	VISIBILITY_CHANGED,
+	DISPLAY_MODE_CHANGED,
+	INTERPOLATION_CHANGED,
+	NUM_TIMEPOINTS_CHANGED,
+	CURRENT_TIMEPOINT_CHANGED,
+	VIEWER_TRANSFORM_CHANGED;
+}
diff --git a/src/main/java/bdv/BehaviourTransformEventHandlerFactory.java b/src/main/java/bdv/viewer/ViewerStateChangeListener.java
similarity index 72%
rename from src/main/java/bdv/BehaviourTransformEventHandlerFactory.java
rename to src/main/java/bdv/viewer/ViewerStateChangeListener.java
index 762d835bca7dc5a783a17d35efe9ba15743bb110..c4db593af6d975c796b096f78ee6a9849727a1d6 100644
--- a/src/main/java/bdv/BehaviourTransformEventHandlerFactory.java
+++ b/src/main/java/bdv/viewer/ViewerStateChangeListener.java
@@ -1,9 +1,8 @@
 /*-
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -27,13 +26,15 @@
  * POSSIBILITY OF SUCH DAMAGE.
  * #L%
  */
-package bdv;
+package bdv.viewer;
 
-import org.scijava.ui.behaviour.io.InputTriggerConfig;
-
-import net.imglib2.ui.TransformEventHandlerFactory;
-
-public interface BehaviourTransformEventHandlerFactory< A > extends TransformEventHandlerFactory< A >
+/**
+ * {@link ViewerStateChangeListener}s are notified about BigDataViewer state
+ * changes.
+ *
+ * @author Tobias Pietzsch
+ */
+public interface ViewerStateChangeListener
 {
-	public void setConfig( final InputTriggerConfig config );
+	void viewerStateChanged( ViewerStateChange change );
 }
diff --git a/src/main/java/bdv/viewer/VisibilityAndGrouping.java b/src/main/java/bdv/viewer/VisibilityAndGrouping.java
index 18a01a5b290a690bd7dd85579f4cf55beafbc9ee..894f2b9beeda4011a9fef2470b3c78e3fbfbb5e4 100644
--- a/src/main/java/bdv/viewer/VisibilityAndGrouping.java
+++ b/src/main/java/bdv/viewer/VisibilityAndGrouping.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -29,36 +28,30 @@
  */
 package bdv.viewer;
 
+import bdv.viewer.state.SourceGroup;
+import bdv.viewer.state.SourceState;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
 import static bdv.viewer.DisplayMode.FUSED;
 import static bdv.viewer.DisplayMode.FUSEDGROUP;
 import static bdv.viewer.DisplayMode.GROUP;
 import static bdv.viewer.DisplayMode.SINGLE;
-import static bdv.viewer.VisibilityAndGrouping.Event.CURRENT_GROUP_CHANGED;
-import static bdv.viewer.VisibilityAndGrouping.Event.CURRENT_SOURCE_CHANGED;
-import static bdv.viewer.VisibilityAndGrouping.Event.DISPLAY_MODE_CHANGED;
-import static bdv.viewer.VisibilityAndGrouping.Event.GROUP_ACTIVITY_CHANGED;
-import static bdv.viewer.VisibilityAndGrouping.Event.GROUP_NAME_CHANGED;
-import static bdv.viewer.VisibilityAndGrouping.Event.SOURCE_ACTVITY_CHANGED;
-import static bdv.viewer.VisibilityAndGrouping.Event.SOURCE_TO_GROUP_ASSIGNMENT_CHANGED;
-import static bdv.viewer.VisibilityAndGrouping.Event.VISIBILITY_CHANGED;
-
-import java.util.Arrays;
-import java.util.List;
-import java.util.SortedSet;
-import java.util.concurrent.CopyOnWriteArrayList;
-
-import bdv.viewer.state.SourceGroup;
-import bdv.viewer.state.SourceState;
-import bdv.viewer.state.ViewerState;
 
 /**
+ * @deprecated This is not necessary anymore, because {@link ViewerState} can be modified directly.
+ * (See {@link ViewerPanel#state()}.)
+ *
  * Manage visibility and currentness of sources and groups, as well as grouping
  * of sources, and display mode.
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
+@Deprecated
 public class VisibilityAndGrouping
 {
+	@Deprecated
 	public static final class Event
 	{
 		public static final int CURRENT_SOURCE_CHANGED = 0;
@@ -92,56 +85,108 @@ public class VisibilityAndGrouping
 		}
 	}
 
+	@Deprecated
 	public interface UpdateListener
 	{
-		public void visibilityChanged( Event e );
+		void visibilityChanged( Event e );
 	}
 
 	protected final CopyOnWriteArrayList< UpdateListener > updateListeners;
 
-	protected final ViewerState state;
+	private final bdv.viewer.state.ViewerState deprecatedViewerState;
+
+	private final ViewerState state;
 
-	public VisibilityAndGrouping( final ViewerState viewerState )
+	@Deprecated
+	public ViewerState getState()
+	{
+		return state;
+	}
+
+	@Deprecated
+	public VisibilityAndGrouping( final bdv.viewer.state.ViewerState viewerState )
 	{
 		updateListeners = new CopyOnWriteArrayList<>();
-		state = viewerState;
+		deprecatedViewerState = viewerState;
+		state = viewerState.getState();
+		viewerState.getState().changeListeners().add( e ->
+		{
+			switch ( e )
+			{
+			case CURRENT_SOURCE_CHANGED:
+				update( Event.CURRENT_SOURCE_CHANGED );
+				break;
+			case CURRENT_GROUP_CHANGED:
+				update( Event.CURRENT_GROUP_CHANGED );
+				break;
+			case SOURCE_ACTIVITY_CHANGED:
+				update( Event.SOURCE_ACTVITY_CHANGED );
+				break;
+			case GROUP_ACTIVITY_CHANGED:
+				update( Event.GROUP_ACTIVITY_CHANGED );
+				break;
+			case SOURCE_TO_GROUP_ASSIGNMENT_CHANGED:
+				update( Event.SOURCE_TO_GROUP_ASSIGNMENT_CHANGED );
+				break;
+			case GROUP_NAME_CHANGED:
+				update( Event.GROUP_NAME_CHANGED );
+				break;
+			case NUM_SOURCES_CHANGED:
+				update( Event.NUM_SOURCES_CHANGED );
+				break;
+			case NUM_GROUPS_CHANGED:
+				update( Event.NUM_GROUPS_CHANGED );
+				break;
+			case VISIBILITY_CHANGED:
+				update( Event.VISIBILITY_CHANGED );
+				break;
+			case DISPLAY_MODE_CHANGED:
+				update( Event.DISPLAY_MODE_CHANGED );
+				break;
+			}
+		} );
 	}
 
+	@Deprecated
 	public int numSources()
 	{
-		return state.numSources();
+		return state.getSources().size();
 	}
 
+	@Deprecated
 	public List< SourceState< ? > > getSources()
 	{
-		return state.getSources();
+		return deprecatedViewerState.getSources();
 	}
 
+	@Deprecated
 	public int numGroups()
 	{
-		return state.numSourceGroups();
+		return state.getGroups().size();
 	}
 
+	@Deprecated
 	public List< SourceGroup > getSourceGroups()
 	{
-		return state.getSourceGroups();
+		return deprecatedViewerState.getSourceGroups();
 	}
 
+	@Deprecated
 	public synchronized DisplayMode getDisplayMode()
 	{
 		return state.getDisplayMode();
 	}
 
+	@Deprecated
 	public synchronized void setDisplayMode( final DisplayMode displayMode )
 	{
 		state.setDisplayMode( displayMode );
-		checkVisibilityChange();
-		update( DISPLAY_MODE_CHANGED );
 	}
 
+	@Deprecated
 	public synchronized int getCurrentSource()
 	{
-		return state.getCurrentSource();
+		return state.getSources().indexOf( state.getCurrentSource() );
 	}
 
 	/**
@@ -149,29 +194,28 @@ public class VisibilityAndGrouping
 	 *
 	 * @param sourceIndex
 	 */
+	@Deprecated
 	public synchronized void setCurrentSource( final int sourceIndex )
 	{
 		if ( sourceIndex < 0 || sourceIndex >= numSources() )
 			return;
 
-		state.setCurrentSource( sourceIndex );
-		checkVisibilityChange();
-		update( CURRENT_SOURCE_CHANGED );
+		state.setCurrentSource( state.getSources().get( sourceIndex ) );
 	};
 
+	@Deprecated
 	public synchronized void setCurrentSource( final Source< ? > source )
 	{
-		state.setCurrentSource( source );
-		checkVisibilityChange();
-		update( CURRENT_SOURCE_CHANGED );
+		state.setCurrentSource( soc( source ) );
 	};
 
+	@Deprecated
 	public synchronized boolean isSourceActive( final int sourceIndex )
 	{
 		if ( sourceIndex < 0 || sourceIndex >= numSources() )
 			return false;
 
-		return state.getSources().get( sourceIndex ).isActive();
+		return state.isSourceActive( state.getSources().get( sourceIndex ) );
 	}
 
 	/**
@@ -180,14 +224,13 @@ public class VisibilityAndGrouping
 	 * @param sourceIndex
 	 * @param isActive
 	 */
+	@Deprecated
 	public synchronized void setSourceActive( final int sourceIndex, final boolean isActive )
 	{
 		if ( sourceIndex < 0 || sourceIndex >= numSources() )
 			return;
 
-		state.getSources().get( sourceIndex ).setActive( isActive );
-		update( SOURCE_ACTVITY_CHANGED );
-		checkVisibilityChange();
+		state.setSourceActive( state.getSources().get( sourceIndex ), isActive );
 	}
 
 	/**
@@ -196,20 +239,16 @@ public class VisibilityAndGrouping
 	 * @param source
 	 * @param isActive
 	 */
+	@Deprecated
 	public synchronized void setSourceActive( final Source< ? > source, final boolean isActive )
 	{
-		for ( final SourceState< ? > s : state.getSources() )
-		{
-			if ( s.getSpimSource().equals( source ) )
-				s.setActive( isActive );
-		}
-		update( SOURCE_ACTVITY_CHANGED );
-		checkVisibilityChange();
+		state.setSourceActive( soc( source ), isActive );
 	}
 
+	@Deprecated
 	public synchronized int getCurrentGroup()
 	{
-		return state.getCurrentGroup();
+		return state.getGroups().indexOf( state.getCurrentGroup() );
 	}
 
 	/**
@@ -217,28 +256,29 @@ public class VisibilityAndGrouping
 	 *
 	 * @param groupIndex
 	 */
+	@Deprecated
 	public synchronized void setCurrentGroup( final int groupIndex )
 	{
 		if ( groupIndex < 0 || groupIndex >= numGroups() )
 			return;
 
-		state.setCurrentGroup( groupIndex );
-		checkVisibilityChange();
-		update( CURRENT_GROUP_CHANGED );
-		final SortedSet< Integer > ids = state.getSourceGroups().get( groupIndex ).getSourceIds();
-		if ( !ids.isEmpty() )
+		final bdv.viewer.SourceGroup group = state.getGroups().get( groupIndex );
+		state.setCurrentGroup( group );
+		final List< SourceAndConverter< ? > > sources = new ArrayList<>( state.getSourcesInGroup( group ) );
+		if ( ! sources.isEmpty() )
 		{
-			state.setCurrentSource( ids.first() );
-			update( CURRENT_SOURCE_CHANGED );
+			sources.sort( state.sourceOrder() );
+			state.setCurrentSource( sources.get( 0 ) );
 		}
 	}
 
+	@Deprecated
 	public synchronized boolean isGroupActive( final int groupIndex )
 	{
 		if ( groupIndex < 0 || groupIndex >= numGroups() )
 			return false;
 
-		return state.getSourceGroups().get( groupIndex ).isActive();
+		return state.isGroupActive( state.getGroups().get( groupIndex ) );
 	}
 
 	/**
@@ -247,49 +287,47 @@ public class VisibilityAndGrouping
 	 * @param groupIndex
 	 * @param isActive
 	 */
+	@Deprecated
 	public synchronized void setGroupActive( final int groupIndex, final boolean isActive )
 	{
 		if ( groupIndex < 0 || groupIndex >= numGroups() )
 			return;
 
-		state.getSourceGroups().get( groupIndex ).setActive( isActive );
-		update( GROUP_ACTIVITY_CHANGED );
-		checkVisibilityChange();
+		state.setGroupActive( state.getGroups().get( groupIndex ), isActive );
 	}
 
+	@Deprecated
 	public synchronized void setGroupName( final int groupIndex, final String name )
 	{
 		if ( groupIndex < 0 || groupIndex >= numGroups() )
 			return;
 
-		state.getSourceGroups().get( groupIndex ).setName( name );
-		update( GROUP_NAME_CHANGED );
+		state.setGroupName( state.getGroups().get( groupIndex ), name );
 	}
 
+	@Deprecated
 	public synchronized void addSourceToGroup( final int sourceIndex, final int groupIndex )
 	{
 		if ( groupIndex < 0 || groupIndex >= numGroups() )
 			return;
 
-		state.getSourceGroups().get( groupIndex ).addSource( sourceIndex );
-		update( SOURCE_TO_GROUP_ASSIGNMENT_CHANGED );
-		checkVisibilityChange();
+		state.addSourceToGroup( state.getSources().get( sourceIndex ), state.getGroups().get( groupIndex ) );
 	}
 
+	@Deprecated
 	public synchronized void removeSourceFromGroup( final int sourceIndex, final int groupIndex )
 	{
 		if ( groupIndex < 0 || groupIndex >= numGroups() )
 			return;
 
-		state.getSourceGroups().get( groupIndex ).removeSource( sourceIndex );
-		update( SOURCE_TO_GROUP_ASSIGNMENT_CHANGED );
-		checkVisibilityChange();
+		state.removeSourceFromGroup( state.getSources().get( sourceIndex ), state.getGroups().get( groupIndex ) );
 	}
 
 	/**
 	 * TODO
 	 * @param index
 	 */
+	@Deprecated
 	public synchronized void setCurrentGroupOrSource( final int index )
 	{
 		if ( isGroupingEnabled() )
@@ -302,6 +340,7 @@ public class VisibilityAndGrouping
 	 * TODO
 	 * @param index
 	 */
+	@Deprecated
 	public synchronized void toggleActiveGroupOrSource( final int index )
 	{
 		if ( isGroupingEnabled() )
@@ -310,64 +349,39 @@ public class VisibilityAndGrouping
 			setSourceActive( index, !isSourceActive( index ) );
 	}
 
+	@Deprecated
 	public synchronized boolean isGroupingEnabled()
 	{
 		final DisplayMode mode = state.getDisplayMode();
 		return ( mode == GROUP ) || ( mode == FUSEDGROUP );
 	}
 
+	@Deprecated
 	public synchronized boolean isFusedEnabled()
 	{
 		final DisplayMode mode = state.getDisplayMode();
 		return ( mode == FUSED ) || ( mode == FUSEDGROUP );
 	}
 
+	@Deprecated
 	public synchronized void setGroupingEnabled( final boolean enable )
 	{
 		setDisplayMode( isFusedEnabled() ? ( enable ? FUSEDGROUP : FUSED ) : ( enable ? GROUP : SINGLE ) );
 	}
 
+	@Deprecated
 	public synchronized void setFusedEnabled( final boolean enable )
 	{
 		setDisplayMode( isGroupingEnabled() ? ( enable ? FUSEDGROUP : GROUP ) : ( enable ? FUSED : SINGLE ) );
 	}
 
+	@Deprecated
 	public synchronized boolean isSourceVisible( final int sourceIndex )
 	{
-		return state.isSourceVisible( sourceIndex );
-	}
-
-	protected boolean[] previousVisibleSources = null;
-
-	protected boolean[] currentVisibleSources = null;
-
-	protected void checkVisibilityChange()
-	{
-		final boolean[] tmp = previousVisibleSources;
-		previousVisibleSources = currentVisibleSources;
-		currentVisibleSources = tmp;
-
-		final int n = numSources();
-		if ( currentVisibleSources == null || currentVisibleSources.length != n )
-			currentVisibleSources = new boolean[ n ];
-		Arrays.fill( currentVisibleSources, false );
-		for ( final int i : state.getVisibleSourceIndices() )
-			currentVisibleSources[ i ] = true;
-
-		if ( previousVisibleSources == null || previousVisibleSources.length != n )
-		{
-			update( VISIBILITY_CHANGED );
-			return;
-		}
-
-		for ( int i = 0; i < currentVisibleSources.length; ++i )
-			if ( currentVisibleSources[ i ] != previousVisibleSources[ i ] )
-			{
-				update( VISIBILITY_CHANGED );
-				return;
-			}
+		return state.isSourceVisibleAndPresent( state.getSources().get( sourceIndex ) );
 	}
 
+	@Deprecated
 	protected void update( final int id )
 	{
 		final Event event = new Event( id, this );
@@ -375,13 +389,24 @@ public class VisibilityAndGrouping
 			l.visibilityChanged( event );
 	}
 
+	@Deprecated
 	public void addUpdateListener( final UpdateListener l )
 	{
 		updateListeners.add( l );
 	}
 
+	@Deprecated
 	public void removeUpdateListener( final UpdateListener l )
 	{
 		updateListeners.remove( l );
 	}
+
+	@Deprecated
+	private SourceAndConverter< ? > soc( Source< ? > source )
+	{
+		for ( SourceAndConverter< ? > soc : state.getSources() )
+			if ( soc.getSpimSource() == source )
+				return soc;
+		return null;
+	}
 }
diff --git a/src/main/java/bdv/viewer/animate/AbstractAnimator.java b/src/main/java/bdv/viewer/animate/AbstractAnimator.java
index 119cd257d9078b31fdf24bf941b5c25d50d30f28..1b24cb2e34e160c6e6432b51f9d55e4301166bab 100644
--- a/src/main/java/bdv/viewer/animate/AbstractAnimator.java
+++ b/src/main/java/bdv/viewer/animate/AbstractAnimator.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -38,8 +37,8 @@ import bdv.viewer.ViewerFrame;
  * from {@link System#currentTimeMillis()} or a frame number when rendering
  * movies.
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
- * @author Jean-Yves Tinevez &lt;jeanyves.tinevez@gmail.com&gt;
+ * @author Tobias Pietzsch
+ * @author Jean-Yves Tinevez
  */
 public class AbstractAnimator
 {
diff --git a/src/main/java/bdv/viewer/animate/AbstractTransformAnimator.java b/src/main/java/bdv/viewer/animate/AbstractTransformAnimator.java
index 73ee9b6bffdafa202fd36b7a3c8d0e5c837fc8b0..d72adc7594e8c045efa6baf08f701b9c40ca8cb6 100644
--- a/src/main/java/bdv/viewer/animate/AbstractTransformAnimator.java
+++ b/src/main/java/bdv/viewer/animate/AbstractTransformAnimator.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -39,8 +38,8 @@ import bdv.viewer.ViewerFrame;
  * example you can use <b>ms</b> obtained from
  * {@link System#currentTimeMillis()} or a frame number when rendering movies.
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
- * @author Jean-Yves Tinevez &lt;jeanyves.tinevez@gmail.com&gt;
+ * @author Tobias Pietzsch
+ * @author Jean-Yves Tinevez
  */
 public abstract class AbstractTransformAnimator extends AbstractAnimator
 {
diff --git a/src/main/java/bdv/viewer/animate/MessageOverlayAnimator.java b/src/main/java/bdv/viewer/animate/MessageOverlayAnimator.java
index c88ada72a830d0d08ab269c8f1de7e50643eeb37..12338cd6cd746cb0ed449505eddb265a435c8555 100644
--- a/src/main/java/bdv/viewer/animate/MessageOverlayAnimator.java
+++ b/src/main/java/bdv/viewer/animate/MessageOverlayAnimator.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -44,7 +43,7 @@ import java.util.List;
  * fading in a specified time. If several messages are drawn at the same time,
  * old messages scroll up.
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
 public class MessageOverlayAnimator implements OverlayAnimator
 {
diff --git a/src/main/java/bdv/viewer/animate/OverlayAnimator.java b/src/main/java/bdv/viewer/animate/OverlayAnimator.java
index d2debb3a621f72a344455734bca0acdbd173b74d..d247dde22df1426b2785b729edf2e831038564a8 100644
--- a/src/main/java/bdv/viewer/animate/OverlayAnimator.java
+++ b/src/main/java/bdv/viewer/animate/OverlayAnimator.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -33,7 +32,7 @@ import java.awt.Graphics2D;
 
 public interface OverlayAnimator
 {
-	public abstract void paint( final Graphics2D g, final long time );
+	void paint( final Graphics2D g, final long time );
 
 	/**
 	 * Returns true if the animation is complete and the animator can be
@@ -42,7 +41,7 @@ public interface OverlayAnimator
 	 * @return whether the animation is complete and the animator can be
 	 *         removed.
 	 */
-	public boolean isComplete();
+	boolean isComplete();
 
 	/**
 	 * Returns true if the animator requires an immediate repaint to continue
@@ -50,5 +49,5 @@ public interface OverlayAnimator
 	 *
 	 * @return whetherhe animator requires an immediate repaint.
 	 */
-	public boolean requiresRepaint();
+	boolean requiresRepaint();
 }
diff --git a/src/main/java/bdv/viewer/animate/RotationAnimator.java b/src/main/java/bdv/viewer/animate/RotationAnimator.java
index d834284ef81cd7f63f5c8c980499d3cc531d055e..6ba0ea2bb7d974e7811c49d8c12f30faa8ff7dcc 100644
--- a/src/main/java/bdv/viewer/animate/RotationAnimator.java
+++ b/src/main/java/bdv/viewer/animate/RotationAnimator.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/viewer/animate/SimilarityTransformAnimator.java b/src/main/java/bdv/viewer/animate/SimilarityTransformAnimator.java
index 6173c38b4e47fe300043539afc38f047a50303d0..c80025d6759469b1713f36c68b3fcb470dc44c44 100644
--- a/src/main/java/bdv/viewer/animate/SimilarityTransformAnimator.java
+++ b/src/main/java/bdv/viewer/animate/SimilarityTransformAnimator.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/viewer/animate/TextOverlayAnimator.java b/src/main/java/bdv/viewer/animate/TextOverlayAnimator.java
index da9f4e3a4b9100ff22ba8ffd9dede27c1849f670..71b20ba3f290f5aa8dc1bf0d1d0c71495aea3c42 100644
--- a/src/main/java/bdv/viewer/animate/TextOverlayAnimator.java
+++ b/src/main/java/bdv/viewer/animate/TextOverlayAnimator.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -40,7 +39,7 @@ import java.awt.geom.Rectangle2D;
  * Draw one line of text in the center or bottom right of the display. Text is
  * fading in and out.
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
 public class TextOverlayAnimator extends AbstractAnimator implements OverlayAnimator
 {
diff --git a/src/main/java/bdv/viewer/animate/TranslationAnimator.java b/src/main/java/bdv/viewer/animate/TranslationAnimator.java
index 03d4ecbe74434bd7c2eb72762c17cf78d5936aa8..d232946eebef22e2a888dd77f246d4bddf662430 100644
--- a/src/main/java/bdv/viewer/animate/TranslationAnimator.java
+++ b/src/main/java/bdv/viewer/animate/TranslationAnimator.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -35,7 +34,7 @@ import net.imglib2.realtransform.AffineTransform3D;
  * An animator that just executes a constant speed translation of the current
  * viewpoint to a target location, keeping all other view parameters constant.
  *
- * @author Jean-Yves Tinevez &lt;jeanyves.tinevez@gmail.com&gt;
+ * @author Jean-Yves Tinevez
  */
 public class TranslationAnimator extends AbstractTransformAnimator
 {
diff --git a/src/main/java/bdv/viewer/overlay/IntervalAndTransform.java b/src/main/java/bdv/viewer/overlay/IntervalAndTransform.java
index 49f5cb640fba50e7377617aa63001a470764657e..acac9d7f20ab5d11e434e2fe1e580dff53b52143 100644
--- a/src/main/java/bdv/viewer/overlay/IntervalAndTransform.java
+++ b/src/main/java/bdv/viewer/overlay/IntervalAndTransform.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/viewer/overlay/MultiBoxOverlay.java b/src/main/java/bdv/viewer/overlay/MultiBoxOverlay.java
index ab6d0a5cc52670fbaea93d0739a4856f72b4c79a..6715b1f7f3c245a46de10648d64f453d7bde951d 100644
--- a/src/main/java/bdv/viewer/overlay/MultiBoxOverlay.java
+++ b/src/main/java/bdv/viewer/overlay/MultiBoxOverlay.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -53,7 +52,7 @@ import net.imglib2.util.LinAlgHelpers;
  * colors depending whether the sources are visible.
  *
  * @author Stephan Saalfeld
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
 public class MultiBoxOverlay
 {
@@ -71,21 +70,21 @@ public class MultiBoxOverlay
 
 	public interface IntervalAndTransform
 	{
-		public boolean isVisible();
+		boolean isVisible();
 
 		/**
 		 * Get interval of the source (stack) in source-local coordinates.
 		 *
 		 * @return extents of the source.
 		 */
-		public Interval getSourceInterval();
+		Interval getSourceInterval();
 
 		/**
 		 * Current transformation from {@link #getSourceInterval() source} to
 		 * viewer. This is a concatenation of source-local-to-global transform
 		 * and the interactive viewer transform.
 		 */
-		public AffineTransform3D getSourceToViewer();
+		AffineTransform3D getSourceToViewer();
 	}
 
 	/**
diff --git a/src/main/java/bdv/viewer/overlay/MultiBoxOverlayRenderer.java b/src/main/java/bdv/viewer/overlay/MultiBoxOverlayRenderer.java
index fb55d9b7626ffc9d5702750e3a1a9c5515cc115d..3b67ba7c661d4c3ddc28975d726ef36e6ef237de 100644
--- a/src/main/java/bdv/viewer/overlay/MultiBoxOverlayRenderer.java
+++ b/src/main/java/bdv/viewer/overlay/MultiBoxOverlayRenderer.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -29,21 +28,20 @@
  */
 package bdv.viewer.overlay;
 
+import bdv.viewer.SourceAndConverter;
+import bdv.viewer.ViewerState;
 import java.awt.Graphics2D;
 import java.util.ArrayList;
 import java.util.List;
-
 import net.imglib2.Interval;
 import net.imglib2.realtransform.AffineTransform3D;
 import net.imglib2.util.Intervals;
-import bdv.viewer.state.SourceState;
-import bdv.viewer.state.ViewerState;
 
 /**
  * Render multibox overlay corresponding to a {@link ViewerState} into a
  * {@link Graphics2D}.
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
 public class MultiBoxOverlayRenderer
 {
@@ -119,19 +117,32 @@ public class MultiBoxOverlayRenderer
 	/**
 	 * Update data to show in the box overlay.
 	 */
-	public synchronized void setViewerState( final ViewerState viewerState )
+	@Deprecated
+	public synchronized void setViewerState( final bdv.viewer.state.ViewerState viewerState )
 	{
 		synchronized ( viewerState )
 		{
-			final List< SourceState< ? > > sources = viewerState.getSources();
-			final List< Integer > visible = viewerState.getVisibleSourceIndices();
-			final int timepoint = viewerState.getCurrentTimepoint();
-
-			final int numSources = sources.size();
-			int numPresentSources = 0;
-			for ( final SourceState< ? > source : sources )
-				if ( source.getSpimSource().isPresent( timepoint ) )
-					numPresentSources++;
+			setViewerState( viewerState.getState() );
+		}
+	}
+
+	/**
+	 * Update data to show in the box overlay.
+	 */
+	public synchronized void setViewerState( final ViewerState state )
+	{
+		synchronized ( state )
+		{
+			final List< SourceAndConverter< ? > > sources = state.getSources();
+			final int timepoint = state.getCurrentTimepoint();
+
+			final List< SourceAndConverter< ? > > presentSources = new ArrayList<>();
+			sources.forEach( s -> {
+				if ( s.getSpimSource().isPresent( timepoint ) )
+					presentSources.add( s );
+			} );
+
+			final int numPresentSources = presentSources.size();
 			if ( boxSources.size() != numPresentSources )
 			{
 				while ( boxSources.size() < numPresentSources )
@@ -140,21 +151,17 @@ public class MultiBoxOverlayRenderer
 					boxSources.remove( boxSources.size() - 1 );
 			}
 
-			final AffineTransform3D sourceToViewer = new AffineTransform3D();
+			final AffineTransform3D sourceToViewer = state.getViewerTransform();
 			final AffineTransform3D sourceTransform = new AffineTransform3D();
-			for ( int i = 0, j = 0; i < numSources; ++i )
+			int i = 0;
+			for ( final SourceAndConverter< ? > source : presentSources )
 			{
-				final SourceState< ? > source = sources.get( i );
-				if ( source.getSpimSource().isPresent( timepoint ) )
-				{
-					final IntervalAndTransform boxsource = boxSources.get( j++ );
-					viewerState.getViewerTransform( sourceToViewer );
-					source.getSpimSource().getSourceTransform( timepoint, 0, sourceTransform );
-					sourceToViewer.concatenate( sourceTransform );
-					boxsource.setSourceToViewer( sourceToViewer );
-					boxsource.setSourceInterval( source.getSpimSource().getSource( timepoint, 0 ) );
-					boxsource.setVisible( visible.contains( i ) );
-				}
+				final IntervalAndTransform boxsource = boxSources.get( i++ );
+				source.getSpimSource().getSourceTransform( timepoint, 0, sourceTransform );
+				sourceTransform.preConcatenate( sourceToViewer );
+				boxsource.setSourceToViewer( sourceTransform );
+				boxsource.setSourceInterval( source.getSpimSource().getSource( timepoint, 0 ) );
+				boxsource.setVisible( state.isSourceVisible( source ) );
 			}
 		}
 	}
diff --git a/src/main/java/bdv/viewer/overlay/RenderBoxHelper.java b/src/main/java/bdv/viewer/overlay/RenderBoxHelper.java
index a649cfcbb4d314fd95c781586b7ee887c6bb35f6..4c24b7d540ca57ee66865b171558e341db2e1bb3 100644
--- a/src/main/java/bdv/viewer/overlay/RenderBoxHelper.java
+++ b/src/main/java/bdv/viewer/overlay/RenderBoxHelper.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -38,7 +37,7 @@ import net.imglib2.realtransform.AffineTransform3D;
  * Helper for rendering overlay boxes.
  *
  * @author Stephan Saalfeld
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
 public class RenderBoxHelper
 {
diff --git a/src/main/java/bdv/viewer/overlay/ScaleBarOverlayRenderer.java b/src/main/java/bdv/viewer/overlay/ScaleBarOverlayRenderer.java
index e238cd09de6aa60681f52cca185cd916fbd015f1..05c49f2a21007775772f1ca1b1fd2e6767e4a8d1 100644
--- a/src/main/java/bdv/viewer/overlay/ScaleBarOverlayRenderer.java
+++ b/src/main/java/bdv/viewer/overlay/ScaleBarOverlayRenderer.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -36,15 +35,15 @@ import java.awt.font.FontRenderContext;
 import java.awt.font.TextLayout;
 import java.awt.geom.Rectangle2D;
 import java.text.DecimalFormat;
-import java.util.List;
 
-import mpicbg.spim.data.sequence.VoxelDimensions;
 import net.imglib2.realtransform.AffineTransform3D;
+
 import bdv.util.Affine3DHelpers;
 import bdv.util.Prefs;
 import bdv.viewer.Source;
-import bdv.viewer.state.SourceState;
-import bdv.viewer.state.ViewerState;
+import bdv.viewer.SourceAndConverter;
+import bdv.viewer.ViewerState;
+import mpicbg.spim.data.sequence.VoxelDimensions;
 
 public class ScaleBarOverlayRenderer
 {
@@ -113,6 +112,18 @@ public class ScaleBarOverlayRenderer
 
 	private static final String[] lengthUnits = { "nm", "µm", "mm", "m", "km" };
 
+	/**
+	 * Update data to show in the overlay.
+	 */
+	@Deprecated
+	public synchronized void setViewerState( final bdv.viewer.state.ViewerState state )
+	{
+		synchronized ( state )
+		{
+			setViewerState( state.getState() );
+		}
+	}
+
 	/**
 	 * Update data to show in the overlay.
 	 */
@@ -120,82 +131,85 @@ public class ScaleBarOverlayRenderer
 	{
 		synchronized ( state )
 		{
-			final List< SourceState< ? > > sources = state.getSources();
-			if ( ! sources.isEmpty() )
+			final SourceAndConverter< ? > current = state.getCurrentSource();
+			if ( current == null )
 			{
-				final Source< ? > spimSource = sources.get( state.getCurrentSource() ).getSpimSource();
-				final VoxelDimensions voxelDimensions = spimSource.getVoxelDimensions();
-				if ( voxelDimensions == null )
-				{
-					drawScaleBar = false;
-					return;
-				}
-				drawScaleBar = true;
+				drawScaleBar = false;
+				return;
+			}
 
-				state.getViewerTransform( transform );
+			final Source< ? > spimSource = current.getSpimSource();
+			final VoxelDimensions voxelDimensions = spimSource.getVoxelDimensions();
+			if ( voxelDimensions == null )
+			{
+				drawScaleBar = false;
+				return;
+			}
+			drawScaleBar = true;
 
-				final int t = state.getCurrentTimepoint();
-				spimSource.getSourceTransform( t, 0, sourceTransform );
-				transform.concatenate( sourceTransform );
-				final double sizeOfOnePixel = voxelDimensions.dimension( 0 ) / Affine3DHelpers.extractScale( transform, 0 );
+			state.getViewerTransform( transform );
 
-				// find good scaleBarLength and corresponding scale value
-				final double sT = targetScaleBarLength * sizeOfOnePixel;
-				final double pot = Math.floor( Math.log10( sT ) );
-				final double l2 =  sT / Math.pow( 10, pot );
-				final int fracs = ( int ) ( 0.1 * l2 * subdivPerPowerOfTen );
-				final double scale1 = ( fracs > 0 ) ? Math.pow( 10, pot + 1 ) * fracs / subdivPerPowerOfTen : Math.pow( 10, pot );
-				final double scale2 = ( fracs == 3 ) ? Math.pow( 10, pot + 1 ) : Math.pow( 10, pot + 1 ) * ( fracs + 1 ) / subdivPerPowerOfTen;
+			final int t = state.getCurrentTimepoint();
+			spimSource.getSourceTransform( t, 0, sourceTransform );
+			transform.concatenate( sourceTransform );
+			final double sizeOfOnePixel = voxelDimensions.dimension( 0 ) / Affine3DHelpers.extractScale( transform, 0 );
 
-				final double lB1 = scale1 / sizeOfOnePixel;
-				final double lB2 = scale2 / sizeOfOnePixel;
+			// find good scaleBarLength and corresponding scale value
+			final double sT = targetScaleBarLength * sizeOfOnePixel;
+			final double pot = Math.floor( Math.log10( sT ) );
+			final double l2 =  sT / Math.pow( 10, pot );
+			final int fracs = ( int ) ( 0.1 * l2 * subdivPerPowerOfTen );
+			final double scale1 = ( fracs > 0 ) ? Math.pow( 10, pot + 1 ) * fracs / subdivPerPowerOfTen : Math.pow( 10, pot );
+			final double scale2 = ( fracs == 3 ) ? Math.pow( 10, pot + 1 ) : Math.pow( 10, pot + 1 ) * ( fracs + 1 ) / subdivPerPowerOfTen;
 
-				if ( Math.abs( lB1 - targetScaleBarLength ) < Math.abs( lB2 - targetScaleBarLength ) )
-				{
-					scale = scale1;
-					scaleBarLength = lB1;
-				}
-				else
+			final double lB1 = scale1 / sizeOfOnePixel;
+			final double lB2 = scale2 / sizeOfOnePixel;
+
+			if ( Math.abs( lB1 - targetScaleBarLength ) < Math.abs( lB2 - targetScaleBarLength ) )
+			{
+				scale = scale1;
+				scaleBarLength = lB1;
+			}
+			else
+			{
+				scale = scale2;
+				scaleBarLength = lB2;
+			}
+
+			// If unit is a known unit (such as nm) then try to modify scale
+			// and unit such that the displayed string is short.
+			// For example, replace "0.021 µm" by "21 nm".
+			String scaleUnit = voxelDimensions.unit();
+			if ( "um".equals( scaleUnit ) )
+				scaleUnit = "µm";
+			int scaleUnitIndex = -1;
+			for ( int i = 0; i < lengthUnits.length; ++i )
+				if ( lengthUnits[ i ].equals( scaleUnit ) )
 				{
-					scale = scale2;
-					scaleBarLength = lB2;
+					scaleUnitIndex = i;
+					break;
 				}
-
-				// If unit is a known unit (such as nm) then try to modify scale
-				// and unit such that the displayed string is short.
-				// For example, replace "0.021 µm" by "21 nm".
-				String scaleUnit = voxelDimensions.unit();
-				if ( "um".equals( scaleUnit ) )
-					scaleUnit = "µm";
-				int scaleUnitIndex = -1;
-				for ( int i = 0; i < lengthUnits.length; ++i )
-					if ( lengthUnits[ i ].equals( scaleUnit ) )
-					{
-						scaleUnitIndex = i;
-						break;
-					}
-				if ( scaleUnitIndex >= 0 )
+			if ( scaleUnitIndex >= 0 )
+			{
+				int shifts = ( int ) Math.floor( ( Math.log10( scale ) + 1 ) / 3 );
+				int shiftedIndex = scaleUnitIndex + shifts;
+				if ( shiftedIndex < 0 )
 				{
-					int shifts = ( int ) Math.floor( ( Math.log10( scale ) + 1 ) / 3 );
-					int shiftedIndex = scaleUnitIndex + shifts;
-					if ( shiftedIndex < 0 )
-					{
-						shifts = -scaleUnitIndex;
-						shiftedIndex = 0;
-					}
-					else if ( shiftedIndex >= lengthUnits.length )
-					{
-						shifts = lengthUnits.length - 1 - scaleUnitIndex;
-						shiftedIndex = lengthUnits.length - 1;
-					}
-
-					scale = scale / Math.pow( 1000, shifts );
-					unit = lengthUnits[ shiftedIndex ];
+					shifts = -scaleUnitIndex;
+					shiftedIndex = 0;
 				}
-				else
+				else if ( shiftedIndex >= lengthUnits.length )
 				{
-					unit = scaleUnit;
+					shifts = lengthUnits.length - 1 - scaleUnitIndex;
+					shiftedIndex = lengthUnits.length - 1;
 				}
+
+				scale = scale / Math.pow( 1000, shifts );
+				unit = lengthUnits[ shiftedIndex ];
+			}
+			else
+			{
+				unit = scaleUnit;
 			}
 		}
 	}
diff --git a/src/main/java/bdv/viewer/overlay/SourceInfoOverlayRenderer.java b/src/main/java/bdv/viewer/overlay/SourceInfoOverlayRenderer.java
index ec6f4d6d5d2b8cb9128ab2bc9152655ab7ed1dd7..cf6b7d598dccb71e6289805058f1c4c274102a34 100644
--- a/src/main/java/bdv/viewer/overlay/SourceInfoOverlayRenderer.java
+++ b/src/main/java/bdv/viewer/overlay/SourceInfoOverlayRenderer.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -29,24 +28,19 @@
  */
 package bdv.viewer.overlay;
 
-import static bdv.viewer.DisplayMode.FUSEDGROUP;
-import static bdv.viewer.DisplayMode.GROUP;
-
 import java.awt.Font;
 import java.awt.Graphics2D;
 import java.util.List;
 
-import bdv.viewer.DisplayMode;
-import bdv.viewer.state.SourceGroup;
-import bdv.viewer.state.SourceState;
-import bdv.viewer.state.ViewerState;
+import bdv.viewer.SourceAndConverter;
+import bdv.viewer.ViewerState;
 import mpicbg.spim.data.sequence.TimePoint;
 
 /**
  * Render current source name and current timepoint of a {@link ViewerState}
  * into a {@link Graphics2D}.
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
 public class SourceInfoOverlayRenderer
 {
@@ -71,6 +65,18 @@ public class SourceInfoOverlayRenderer
 		this.timePointsOrdered = timePointsOrdered;
 	}
 
+	/**
+	 * Update data to show in the overlay.
+	 */
+	@Deprecated
+	public synchronized void setViewerState( final bdv.viewer.state.ViewerState state )
+	{
+		synchronized ( state )
+		{
+			setViewerState( state.getState() );
+		}
+	}
+
 	/**
 	 * Update data to show in the overlay.
 	 */
@@ -78,18 +84,13 @@ public class SourceInfoOverlayRenderer
 	{
 		synchronized ( state )
 		{
-			final List< SourceState< ? > > sources = state.getSources();
-			if ( ! sources.isEmpty() )
-				sourceName = sources.get( state.getCurrentSource() ).getSpimSource().getName();
-			else
-				sourceName = "";
+			final SourceAndConverter< ? > currentSource = state.getCurrentSource();
+			sourceName = currentSource != null
+					? currentSource.getSpimSource().getName() : "";
 
-			final List< SourceGroup > groups = state.getSourceGroups();
-			final DisplayMode mode = state.getDisplayMode();
-			if ( ( mode == GROUP || mode == FUSEDGROUP ) && ! groups.isEmpty() )
-				groupName = groups.get( state.getCurrentGroup() ).getName();
-			else
-				groupName = "";
+			final bdv.viewer.SourceGroup currentGroup = state.getCurrentGroup();
+			groupName = currentGroup != null && state.getDisplayMode().hasGrouping()
+					? state.getGroupName( currentGroup ) : "";
 
 			final int t = state.getCurrentTimepoint();
 			if ( timePointsOrdered != null && t >= 0 && t < timePointsOrdered.size() )
diff --git a/src/main/java/bdv/viewer/render/AccumulateProjector.java b/src/main/java/bdv/viewer/render/AccumulateProjector.java
index eadc68de9760f35485e561cb8ff908494c36f500..91be77098862c3dec46629db2bb75981534b836b 100644
--- a/src/main/java/bdv/viewer/render/AccumulateProjector.java
+++ b/src/main/java/bdv/viewer/render/AccumulateProjector.java
@@ -1,19 +1,18 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
- *
+ * 
  * 1. Redistributions of source code must retain the above copyright notice,
  *    this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
  *    this list of conditions and the following disclaimer in the documentation
  *    and/or other materials provided with the distribution.
- *
+ * 
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
@@ -30,56 +29,63 @@
 package bdv.viewer.render;
 
 import java.util.ArrayList;
+import java.util.List;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.atomic.AtomicBoolean;
-
 import net.imglib2.Cursor;
 import net.imglib2.IterableInterval;
 import net.imglib2.RandomAccessible;
 import net.imglib2.RandomAccessibleInterval;
-import net.imglib2.ui.InterruptibleProjector;
-import net.imglib2.ui.util.StopWatch;
+import net.imglib2.util.StopWatch;
 import net.imglib2.view.Views;
 
+// TODO javadoc
 public abstract class AccumulateProjector< A, B > implements VolatileProjector
 {
-	protected final ArrayList< VolatileProjector > sourceProjectors;
+	/**
+	 * Projectors that render the source images to accumulate.
+	 * For every rendering pass, ({@link VolatileProjector#map(boolean)}) is run on each source projector that is not yet {@link VolatileProjector#isValid() valid}.
+	 */
+	private final List< VolatileProjector > sourceProjectors;
 
-	protected final ArrayList< IterableInterval< ? extends A > > sources;
+	/**
+	 * The source images to accumulate
+	 */
+	private final List< IterableInterval< ? extends A > > sources;
 
 	/**
 	 * The target interval. Pixels of the target interval should be set by
-	 * {@link InterruptibleProjector#map()}
+	 * {@link #map}
 	 */
-	protected final RandomAccessibleInterval< B > target;
+	private final RandomAccessibleInterval< B > target;
 
 	/**
 	 * A reference to the target image as an iterable.  Used for source-less
 	 * operations such as clearing its content.
 	 */
-	protected final IterableInterval< B > iterableTarget;
+	private final IterableInterval< B > iterableTarget;
 
 	/**
      * Number of threads to use for rendering
      */
-    protected final int numThreads;
+    private final int numThreads;
 
-	protected final ExecutorService executorService;
+	private final ExecutorService executorService;
 
     /**
      * Time needed for rendering the last frame, in nano-seconds.
      */
-    protected long lastFrameRenderNanoTime;
+    private long lastFrameRenderNanoTime;
 
-	protected final AtomicBoolean interrupted = new AtomicBoolean();
+	private final AtomicBoolean canceled = new AtomicBoolean();
 
-	protected volatile boolean valid = false;
+	private volatile boolean valid = false;
 
 	public AccumulateProjector(
-			final ArrayList< VolatileProjector > sourceProjectors,
-			final ArrayList< ? extends RandomAccessible< ? extends A > > sources,
+			final List< VolatileProjector > sourceProjectors,
+			final List< ? extends RandomAccessible< ? extends A > > sources,
 			final RandomAccessibleInterval< B > target,
 			final int numThreads,
 			final ExecutorService executorService )
@@ -95,19 +101,12 @@ public abstract class AccumulateProjector< A, B > implements VolatileProjector
 		lastFrameRenderNanoTime = -1;
 	}
 
-	@Override
-	public boolean map()
-	{
-		return map( true );
-	}
-
 	@Override
 	public boolean map( final boolean clearUntouchedTargetPixels )
 	{
-		interrupted.set( false );
+		canceled.set( false );
 
-		final StopWatch stopWatch = new StopWatch();
-		stopWatch.start();
+		final StopWatch stopWatch = StopWatch.createAndStart();
 
 		valid = true;
 		for ( final VolatileProjector p : sourceProjectors )
@@ -119,49 +118,21 @@ public abstract class AccumulateProjector< A, B > implements VolatileProjector
 
 		final int width = ( int ) target.dimension( 0 );
 		final int height = ( int ) target.dimension( 1 );
-		final int length = width * height;
+		final int size = width * height;
+
+		final int numTasks = numThreads <= 1 ? 1 : Math.min( numThreads * 10, height );
+		final double taskLength = ( double ) size / numTasks;
+		final int[] taskOffsets = new int[ numTasks + 1 ];
+		for ( int i = 0; i < numTasks; ++i )
+			taskOffsets[ i ] = ( int ) ( i * taskLength );
+		taskOffsets[ numTasks ] = size;
 
 		final boolean createExecutor = ( executorService == null );
 		final ExecutorService ex = createExecutor ? Executors.newFixedThreadPool( numThreads ) : executorService;
-		final int numTasks = Math.min( numThreads * 10, height );
-		final double taskLength = ( double ) length / numTasks;
-		final int numSources = sources.size();
-		final ArrayList< Callable< Void > > tasks = new ArrayList<>( numTasks );
-		for ( int taskNum = 0; taskNum < numTasks; ++taskNum )
-		{
-			final int myOffset = ( int ) ( taskNum * taskLength );
-			final int myLength = ( (taskNum == numTasks - 1 ) ? length : ( int ) ( ( taskNum + 1 ) * taskLength ) ) - myOffset;
-
-			final Callable< Void > r = new Callable< Void >()
-			{
-				@SuppressWarnings( "unchecked" )
-				@Override
-				public Void call()
-				{
-					if ( interrupted.get() )
-						return null;
-
-					final Cursor< ? extends A >[] sourceCursors = new Cursor[ numSources ];
-					for ( int s = 0; s < numSources; ++s )
-					{
-						final Cursor< ? extends A > c = sources.get( s ).cursor();
-						c.jumpFwd( myOffset );
-						sourceCursors[ s ] = c;
-					}
-					final Cursor< B > targetCursor = iterableTarget.cursor();
-					targetCursor.jumpFwd( myOffset );
-
-					for ( int i = 0; i < myLength; ++i )
-					{
-						for ( int s = 0; s < numSources; ++s )
-							sourceCursors[ s ].fwd();
-						accumulate( sourceCursors, targetCursor.next() );
-					}
-					return null;
-				}
-			};
-			tasks.add( r );
-		}
+
+		final List< Callable< Void > > tasks = new ArrayList<>( numTasks );
+		for( int i = 0; i < numTasks; ++i )
+			tasks.add( createMapTask( taskOffsets[ i ], taskOffsets[ i + 1 ] ) );
 		try
 		{
 			ex.invokeAll( tasks );
@@ -175,7 +146,50 @@ public abstract class AccumulateProjector< A, B > implements VolatileProjector
 
 		lastFrameRenderNanoTime = stopWatch.nanoTime();
 
-		return !interrupted.get();
+		return !canceled.get();
+	}
+
+	/**
+	 * @return a {@code Callable} that runs {@code map(startOffset, endOffset)}
+	 */
+	private Callable< Void > createMapTask( final int startOffset, final int endOffset )
+	{
+		return Executors.callable( () -> map( startOffset, endOffset ), null );
+	}
+
+	/**
+	 * Accumulate pixels from {@code startOffset} up to {@code endOffset}
+	 * (exclusive) of all sources to target. Before starting, check
+	 * whether rendering was {@link #cancel() canceled}.
+	 *
+	 * @param startOffset
+	 *     pixel range start (flattened index)
+	 * @param endOffset
+	 *     pixel range end (exclusive, flattened index)
+	 */
+	private void map( final int startOffset, final int endOffset )
+	{
+		if ( canceled.get() )
+			return;
+
+		final int numSources = sources.size();
+		final Cursor< ? extends A >[] sourceCursors = new Cursor[ numSources ];
+		for ( int s = 0; s < numSources; ++s )
+		{
+			final Cursor< ? extends A > c = sources.get( s ).cursor();
+			c.jumpFwd( startOffset );
+			sourceCursors[ s ] = c;
+		}
+		final Cursor< B > targetCursor = iterableTarget.cursor();
+		targetCursor.jumpFwd( startOffset );
+
+		final int size = endOffset - startOffset;
+		for ( int i = 0; i < size; ++i )
+		{
+			for ( int s = 0; s < numSources; ++s )
+				sourceCursors[ s ].fwd();
+			accumulate( sourceCursors, targetCursor.next() );
+		}
 	}
 
 	protected abstract void accumulate( final Cursor< ? extends A >[] accesses, final B target );
@@ -183,7 +197,7 @@ public abstract class AccumulateProjector< A, B > implements VolatileProjector
 	@Override
 	public void cancel()
 	{
-		interrupted.set( true );
+		canceled.set( true );
 		for ( final VolatileProjector p : sourceProjectors )
 			p.cancel();
 	}
diff --git a/src/main/java/bdv/viewer/render/AccumulateProjectorARGB.java b/src/main/java/bdv/viewer/render/AccumulateProjectorARGB.java
index dd84de6d7bbb8feb8c5efa081983b4cca9bd726b..c92260f53354d218e78dc68740ac5e268ce22dfc 100644
--- a/src/main/java/bdv/viewer/render/AccumulateProjectorARGB.java
+++ b/src/main/java/bdv/viewer/render/AccumulateProjectorARGB.java
@@ -1,19 +1,18 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
- *
+ * 
  * 1. Redistributions of source code must retain the above copyright notice,
  *    this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
  *    this list of conditions and the following disclaimer in the documentation
  *    and/or other materials provided with the distribution.
- *
+ * 
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
@@ -29,49 +28,259 @@
  */
 package bdv.viewer.render;
 
+import bdv.viewer.SourceAndConverter;
 import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutorService;
-
-import bdv.viewer.Source;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicBoolean;
 import net.imglib2.Cursor;
+import net.imglib2.Interval;
 import net.imglib2.RandomAccessible;
 import net.imglib2.RandomAccessibleInterval;
 import net.imglib2.type.numeric.ARGBType;
+import net.imglib2.util.Intervals;
+import net.imglib2.util.StopWatch;
 
-public class AccumulateProjectorARGB extends AccumulateProjector< ARGBType, ARGBType >
+public class AccumulateProjectorARGB implements VolatileProjector
 {
 	public static AccumulateProjectorFactory< ARGBType > factory = new AccumulateProjectorFactory< ARGBType >()
 	{
 		@Override
-		public AccumulateProjectorARGB createAccumulateProjector(
-				final ArrayList< VolatileProjector > sourceProjectors,
-				final ArrayList< Source< ? > > sources,
-				final ArrayList< ? extends RandomAccessible< ? extends ARGBType > > sourceScreenImages,
-				final RandomAccessibleInterval< ARGBType > targetScreenImages,
+		public VolatileProjector createProjector(
+				final List< VolatileProjector > sourceProjectors,
+				final List< SourceAndConverter< ? > > sources,
+				final List< ? extends RandomAccessible< ? extends ARGBType > > sourceScreenImages,
+				final RandomAccessibleInterval< ARGBType > targetScreenImage,
 				final int numThreads,
 				final ExecutorService executorService )
 		{
-			return new AccumulateProjectorARGB( sourceProjectors, sourceScreenImages, targetScreenImages, numThreads, executorService );
+			try
+			{
+				return new AccumulateProjectorARGB( sourceProjectors, sourceScreenImages, targetScreenImage, numThreads, executorService );
+			}
+			catch ( IllegalArgumentException ignored )
+			{}
+			return new AccumulateProjectorARGBGeneric( sourceProjectors, sourceScreenImages, targetScreenImage, numThreads, executorService );
 		}
 	};
 
+	public static class AccumulateProjectorARGBGeneric extends AccumulateProjector< ARGBType, ARGBType >
+	{
+		public AccumulateProjectorARGBGeneric(
+				final List< VolatileProjector > sourceProjectors,
+				final List< ? extends RandomAccessible< ? extends ARGBType > > sources,
+				final RandomAccessibleInterval< ARGBType > target,
+				final int numThreads,
+				final ExecutorService executorService )
+		{
+			super( sourceProjectors, sources, target, numThreads, executorService );
+		}
+
+		@Override
+		protected void accumulate( final Cursor< ? extends ARGBType >[] accesses, final ARGBType target )
+		{
+			int aSum = 0, rSum = 0, gSum = 0, bSum = 0;
+			for ( final Cursor< ? extends ARGBType > access : accesses )
+			{
+				final int value = access.get().get();
+				final int a = ARGBType.alpha( value );
+				final int r = ARGBType.red( value );
+				final int g = ARGBType.green( value );
+				final int b = ARGBType.blue( value );
+				aSum += a;
+				rSum += r;
+				gSum += g;
+				bSum += b;
+			}
+			if ( aSum > 255 )
+				aSum = 255;
+			if ( rSum > 255 )
+				rSum = 255;
+			if ( gSum > 255 )
+				gSum = 255;
+			if ( bSum > 255 )
+				bSum = 255;
+			target.set( ARGBType.rgba( rSum, gSum, bSum, aSum ) );
+		}
+	}
+
+	/**
+	 * Projectors that render the source images to accumulate.
+	 * For every rendering pass, ({@link VolatileProjector#map(boolean)}) is run on each source projector that is not yet {@link VolatileProjector#isValid() valid}.
+	 */
+	private final List< VolatileProjector > sourceProjectors;
+
+	/**
+	 * The source images to accumulate
+	 */
+	private final List< ? extends RandomAccessible< ? extends ARGBType > > sources;
+
+	private final int[][] sourceData;
+
+	/**
+	 * The target interval. Pixels of the target interval should be set by
+	 * {@link #map}
+	 */
+	private final RandomAccessibleInterval< ARGBType > target;
+
+	private final int[] targetData;
+
+	/**
+     * Number of threads to use for rendering
+     */
+    private final int numThreads;
+
+	private final ExecutorService executorService;
+
+    /**
+     * Time needed for rendering the last frame, in nano-seconds.
+     */
+    private long lastFrameRenderNanoTime;
+
+	private final AtomicBoolean canceled = new AtomicBoolean();
+
+	private volatile boolean valid = false;
+
 	public AccumulateProjectorARGB(
-			final ArrayList< VolatileProjector > sourceProjectors,
-			final ArrayList< ? extends RandomAccessible< ? extends ARGBType > > sources,
+			final List< VolatileProjector > sourceProjectors,
+			final List< ? extends RandomAccessible< ? extends ARGBType > > sources,
 			final RandomAccessibleInterval< ARGBType > target,
 			final int numThreads,
 			final ExecutorService executorService )
 	{
-		super( sourceProjectors, sources, target, numThreads, executorService );
+		this.sourceProjectors = sourceProjectors;
+		this.sources = sources;
+		this.target = target;
+		this.numThreads = numThreads;
+		this.executorService = executorService;
+		lastFrameRenderNanoTime = -1;
+
+		targetData = ProjectorUtils.getARGBArrayImgData( target );
+		if ( targetData == null )
+			throw new IllegalArgumentException();
+
+		final int numSources = sources.size();
+		sourceData = new int[ numSources ][];
+		for ( int i = 0; i < numSources; ++i )
+		{
+			final RandomAccessible< ? extends ARGBType > source = sources.get( i );
+			if ( ! ( source instanceof RandomAccessibleInterval ) )
+				throw new IllegalArgumentException();
+			if ( ! Intervals.equals( target, ( Interval ) source ) )
+				throw new IllegalArgumentException();
+			sourceData[ i ] = ProjectorUtils.getARGBArrayImgData( source );
+			if ( sourceData[ i ] == null )
+				throw new IllegalArgumentException();
+		}
+	}
+
+	@Override
+	public boolean map( final boolean clearUntouchedTargetPixels )
+	{
+		if ( canceled.get() )
+			return false;
+
+		final StopWatch stopWatch = StopWatch.createAndStart();
+
+		valid = true;
+		for ( final VolatileProjector p : sourceProjectors )
+			if ( !p.isValid() )
+				if ( !p.map( clearUntouchedTargetPixels ) )
+					return false;
+				else
+					valid &= p.isValid();
+
+		final int size = ( int ) Intervals.numElements( target );
+
+		final int numTasks = numThreads <= 1 ? 1 : Math.min( numThreads * 10, size );
+		final double taskLength = ( double ) size / numTasks;
+		final int[] taskOffsets = new int[ numTasks + 1 ];
+		for ( int i = 0; i < numTasks; ++i )
+			taskOffsets[ i ] = ( int ) ( i * taskLength );
+		taskOffsets[ numTasks ] = size;
+
+		final boolean createExecutor = ( executorService == null );
+		final ExecutorService ex = createExecutor ? Executors.newFixedThreadPool( numThreads ) : executorService;
+
+		final List< Callable< Void > > tasks = new ArrayList<>( numTasks );
+		for( int i = 0; i < numTasks; ++i )
+			tasks.add( createMapTask( taskOffsets[ i ], taskOffsets[ i + 1 ] ) );
+		try
+		{
+			ex.invokeAll( tasks );
+		}
+		catch ( final InterruptedException e )
+		{
+			Thread.currentThread().interrupt();
+		}
+		if ( createExecutor )
+			ex.shutdown();
+
+		lastFrameRenderNanoTime = stopWatch.nanoTime();
+
+		return !canceled.get();
+	}
+
+	@Override
+	public void cancel()
+	{
+		canceled.set( true );
+		for ( final VolatileProjector p : sourceProjectors )
+			p.cancel();
 	}
 
 	@Override
-	protected void accumulate( final Cursor< ? extends ARGBType >[] accesses, final ARGBType target )
+	public long getLastFrameRenderNanoTime()
+	{
+		return lastFrameRenderNanoTime;
+	}
+
+	@Override
+	public boolean isValid()
+	{
+		return valid;
+	}
+
+	/**
+	 * @return a {@code Callable} that runs {@code map(startOffset, endOffset)}
+	 */
+	private Callable< Void > createMapTask( final int startOffset, final int endOffset )
+	{
+		return Executors.callable( () -> map( startOffset, endOffset ), null );
+	}
+
+	/**
+	 * Accumulate pixels from {@code startOffset} up to {@code endOffset}
+	 * (exclusive) of all sources to target. Before starting, check
+	 * whether rendering was {@link #cancel() canceled}.
+	 *
+	 * @param startOffset
+	 *     pixel range start (flattened index)
+	 * @param endOffset
+	 *     pixel range end (exclusive, flattened index)
+	 */
+	private void map( final int startOffset, final int endOffset )
+	{
+		if ( canceled.get() )
+			return;
+
+		final int numSources = sources.size();
+		final int[] values = new int[ numSources ];
+		for ( int i = startOffset; i < endOffset; ++i )
+		{
+			for ( int s = 0; s < numSources; ++s )
+				values[ s ] = sourceData[ s ][ i ];
+			targetData[ i ] = accumulate( values );
+		}
+	}
+
+	protected int accumulate( final int[] values )
 	{
 		int aSum = 0, rSum = 0, gSum = 0, bSum = 0;
-		for ( final Cursor< ? extends ARGBType > access : accesses )
+		for ( final int value : values )
 		{
-			final int value = access.get().get();
 			final int a = ARGBType.alpha( value );
 			final int r = ARGBType.red( value );
 			final int g = ARGBType.green( value );
@@ -89,6 +298,6 @@ public class AccumulateProjectorARGB extends AccumulateProjector< ARGBType, ARGB
 			gSum = 255;
 		if ( bSum > 255 )
 			bSum = 255;
-		target.set( ARGBType.rgba( rSum, gSum, bSum, aSum ) );
+		return ARGBType.rgba( rSum, gSum, bSum, aSum );
 	}
 }
diff --git a/src/main/java/bdv/viewer/render/AccumulateProjectorFactory.java b/src/main/java/bdv/viewer/render/AccumulateProjectorFactory.java
index 2bba999b662d21011f66cc53bbebb2593ae7a925..8a141730781be7eca6c1decbed8fa39c48fbb94d 100644
--- a/src/main/java/bdv/viewer/render/AccumulateProjectorFactory.java
+++ b/src/main/java/bdv/viewer/render/AccumulateProjectorFactory.java
@@ -1,19 +1,18 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
- *
+ * 
  * 1. Redistributions of source code must retain the above copyright notice,
  *    this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
  *    this list of conditions and the following disclaimer in the documentation
  *    and/or other materials provided with the distribution.
- *
+ * 
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
@@ -29,7 +28,9 @@
  */
 package bdv.viewer.render;
 
+import bdv.viewer.SourceAndConverter;
 import java.util.ArrayList;
+import java.util.List;
 import java.util.concurrent.ExecutorService;
 
 import bdv.viewer.Source;
@@ -53,11 +54,52 @@ public interface AccumulateProjectorFactory< A >
 	 * @param executorService
 	 *            {@link ExecutorService} to use for rendering. may be null.
 	 */
-	public VolatileProjector createAccumulateProjector(
+	default VolatileProjector createProjector(
+			final List< VolatileProjector > sourceProjectors,
+			final List< SourceAndConverter< ? > > sources,
+			final List< ? extends RandomAccessible< ? extends A > > sourceScreenImages,
+			final RandomAccessibleInterval< A > targetScreenImage,
+			final int numThreads,
+			final ExecutorService executorService )
+	{
+		final ArrayList< Source< ? > > spimSources = new ArrayList<>();
+		for ( SourceAndConverter< ? > source : sources )
+			spimSources.add( source.getSpimSource() );
+		final ArrayList< VolatileProjector > sp = sourceProjectors instanceof ArrayList
+				? ( ArrayList ) sourceProjectors
+				: new ArrayList<>( sourceProjectors );
+		final ArrayList< ? extends RandomAccessible< ? extends A > > si = sourceScreenImages instanceof ArrayList
+				? ( ArrayList ) sourceScreenImages
+				: new ArrayList<>( sourceScreenImages );
+		return createAccumulateProjector( sp, spimSources, si, targetScreenImage, numThreads, executorService );
+	}
+
+	/**
+	 * @deprecated Use {@link #createProjector(List, List, List, RandomAccessibleInterval, int, ExecutorService)} instead.
+	 *
+	 * @param sourceProjectors
+	 *            projectors that will be used to render {@code sources}.
+	 * @param sources
+	 *            sources to identify which channels are being rendered
+	 * @param sourceScreenImages
+	 *            rendered images that will be accumulated into
+	 *            {@code target}.
+	 * @param targetScreenImage
+	 *            final image to render.
+	 * @param numThreads
+	 *            how many threads to use for rendering.
+	 * @param executorService
+	 *            {@link ExecutorService} to use for rendering. may be null.
+	 */
+	@Deprecated
+	default VolatileProjector createAccumulateProjector(
 			final ArrayList< VolatileProjector > sourceProjectors,
 			final ArrayList< Source< ? > > sources,
 			final ArrayList< ? extends RandomAccessible< ? extends A > > sourceScreenImages,
 			final RandomAccessibleInterval< A > targetScreenImage,
 			final int numThreads,
-			final ExecutorService executorService );
+			final ExecutorService executorService )
+	{
+		throw new UnsupportedOperationException( "AccumulateProjectorFactory::createAccumulateProjector is deprecated and by default not implemented" );
+	}
 }
diff --git a/src/main/java/bdv/viewer/render/DefaultMipmapOrdering.java b/src/main/java/bdv/viewer/render/DefaultMipmapOrdering.java
index 3ad8f96cae9abe3fc892392f57fd371c00b1d6a2..332bc1cdf392509ec71b6bbf18b96b18a227bf40 100644
--- a/src/main/java/bdv/viewer/render/DefaultMipmapOrdering.java
+++ b/src/main/java/bdv/viewer/render/DefaultMipmapOrdering.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -54,7 +53,7 @@ import net.imglib2.realtransform.AffineTransform3D;
  * between images that have all data present already or that we move to a new
  * image with no data present at all.
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
 public class DefaultMipmapOrdering implements MipmapOrdering
 {
diff --git a/src/main/java/bdv/viewer/render/EmptyProjector.java b/src/main/java/bdv/viewer/render/EmptyProjector.java
index 3562d3086089a0f59064c8067acf0e00c79c141f..63a40b4f3edb34ea034e43f1a9aa286c6262a764 100644
--- a/src/main/java/bdv/viewer/render/EmptyProjector.java
+++ b/src/main/java/bdv/viewer/render/EmptyProjector.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -29,9 +28,15 @@
  */
 package bdv.viewer.render;
 
+import java.util.Arrays;
 import net.imglib2.RandomAccessibleInterval;
+import net.imglib2.img.array.ArrayImg;
+import net.imglib2.img.basictypeaccess.array.IntArray;
+import net.imglib2.type.numeric.ARGBType;
 import net.imglib2.type.numeric.NumericType;
-import net.imglib2.ui.util.StopWatch;
+import net.imglib2.util.Intervals;
+import net.imglib2.util.StopWatch;
+import net.imglib2.util.Util;
 import net.imglib2.view.Views;
 
 public class EmptyProjector< T extends NumericType< T> > implements VolatileProjector
@@ -46,20 +51,24 @@ public class EmptyProjector< T extends NumericType< T> > implements VolatileProj
 		lastFrameRenderNanoTime = -1;
 	}
 
-	@Override
-	public boolean map()
-	{
-		return map( false );
-	}
-
 	@Override
 	public boolean map( final boolean clearUntouchedTargetPixels )
 	{
-		final StopWatch stopWatch = new StopWatch();
-		stopWatch.start();
+		final StopWatch stopWatch = StopWatch.createAndStart();
 		if ( clearUntouchedTargetPixels )
-			for ( final T t : Views.iterable( target ) )
-				t.setZero();
+		{
+			final int[] data = ProjectorUtils.getARGBArrayImgData( target );
+			if ( data != null )
+			{
+				final int size = ( int ) Intervals.numElements( target );
+				Arrays.fill( data, 0, size, 0 );
+			}
+			else
+			{
+				for ( final T t : Views.iterable( target ) )
+					t.setZero();
+			}
+		}
 		lastFrameRenderNanoTime = stopWatch.nanoTime();
 		return true;
 	}
diff --git a/src/main/java/bdv/viewer/render/MipmapOrdering.java b/src/main/java/bdv/viewer/render/MipmapOrdering.java
index d6779e615ce0f1e8f828dd02092b194d2ce2fdcf..9de2ba7bc0ff14a0e1fb9052760fb2c5571ccdac 100644
--- a/src/main/java/bdv/viewer/render/MipmapOrdering.java
+++ b/src/main/java/bdv/viewer/render/MipmapOrdering.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -48,9 +47,9 @@ public interface MipmapOrdering
 	 * @param previousTimepoint
 	 *            previous timepoint index
 	 */
-	public MipmapHints getMipmapHints( AffineTransform3D screenTransform, int timepoint, int previousTimepoint );
+	MipmapHints getMipmapHints( AffineTransform3D screenTransform, int timepoint, int previousTimepoint );
 
-	public static class Level
+	class Level
 	{
 		// level index in Source
 		private final int mipmapLevel;
@@ -112,7 +111,7 @@ public interface MipmapOrdering
 		}
 	}
 
-	public static class RenderOrderComparator implements Comparator< Level >
+	class RenderOrderComparator implements Comparator< Level >
 	{
 		@Override
 		public int compare( final Level o1, final Level o2 )
@@ -121,7 +120,7 @@ public interface MipmapOrdering
 		}
 	}
 
-	public static class PrefetchOrderComparator implements Comparator< Level >
+	class PrefetchOrderComparator implements Comparator< Level >
 	{
 		@Override
 		public int compare( final Level o1, final Level o2 )
@@ -130,11 +129,11 @@ public interface MipmapOrdering
 		}
 	}
 
-	public static RenderOrderComparator renderOrderComparator = new RenderOrderComparator();
+	RenderOrderComparator renderOrderComparator = new RenderOrderComparator();
 
-	public static PrefetchOrderComparator prefetchOrderComparator = new PrefetchOrderComparator();
+	PrefetchOrderComparator prefetchOrderComparator = new PrefetchOrderComparator();
 
-	public static class MipmapHints
+	class MipmapHints
 	{
 		private final List< Level > levels;
 
diff --git a/src/main/java/bdv/viewer/render/MultiResolutionRenderer.java b/src/main/java/bdv/viewer/render/MultiResolutionRenderer.java
index 924dd0349d37e2721d2739f9d9a31ed8719c07ff..792980a5f4e4239178906374284a8833fef67b49 100644
--- a/src/main/java/bdv/viewer/render/MultiResolutionRenderer.java
+++ b/src/main/java/bdv/viewer/render/MultiResolutionRenderer.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -29,259 +28,218 @@
  */
 package bdv.viewer.render;
 
-import java.awt.image.BufferedImage;
-import java.util.ArrayDeque;
 import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.HashMap;
 import java.util.List;
 import java.util.concurrent.ExecutorService;
 
-import bdv.cache.CacheControl;
-import bdv.img.cache.VolatileCachedCellImg;
-import bdv.viewer.Interpolation;
-import bdv.viewer.Source;
-import bdv.viewer.render.MipmapOrdering.Level;
-import bdv.viewer.render.MipmapOrdering.MipmapHints;
-import bdv.viewer.state.SourceState;
-import bdv.viewer.state.ViewerState;
-import net.imglib2.Dimensions;
-import net.imglib2.RandomAccess;
-import net.imglib2.RandomAccessible;
+import net.imglib2.Interval;
 import net.imglib2.RandomAccessibleInterval;
-import net.imglib2.RealRandomAccessible;
 import net.imglib2.Volatile;
 import net.imglib2.cache.iotiming.CacheIoTiming;
-import net.imglib2.cache.volatiles.CacheHints;
-import net.imglib2.cache.volatiles.LoadingStrategy;
-import net.imglib2.converter.Converter;
-import net.imglib2.display.screenimage.awt.ARGBScreenImage;
 import net.imglib2.realtransform.AffineTransform3D;
-import net.imglib2.realtransform.RealViews;
 import net.imglib2.type.numeric.ARGBType;
-import net.imglib2.ui.PainterThread;
-import net.imglib2.ui.RenderTarget;
-import net.imglib2.ui.Renderer;
-import net.imglib2.ui.SimpleInterruptibleProjector;
-import net.imglib2.ui.TransformListener;
-import net.imglib2.ui.util.GuiUtil;
+import net.imglib2.util.Intervals;
+
+import bdv.cache.CacheControl;
+import bdv.util.MovingAverage;
+import bdv.viewer.RequestRepaint;
+import bdv.viewer.SourceAndConverter;
+import bdv.viewer.ViewerState;
+import bdv.viewer.render.ScreenScales.IntervalRenderData;
+import bdv.viewer.render.ScreenScales.ScreenScale;
 
 /**
- * A {@link Renderer} that uses a coarse-to-fine rendering scheme. First, a
- * small {@link BufferedImage} at a fraction of the canvas resolution is
- * rendered. Then, increasingly larger images are rendered, until the full
- * canvas resolution is reached.
- * <p>
- * When drawing the low-resolution {@link BufferedImage} to the screen, they
- * will be scaled up by Java2D to the full canvas size, which is relatively
- * fast. Rendering the small, low-resolution images is usually very fast, such
- * that the display is very interactive while the user changes the viewing
- * transformation for example. When the transformation remains fixed for a
- * longer period, higher-resolution details are filled in successively.
+ * A renderer that uses a coarse-to-fine rendering scheme. First, a small target
+ * image at a fraction of the canvas resolution is rendered. Then, increasingly
+ * larger images are rendered, until the full canvas resolution is reached.
  * <p>
- * The renderer allocates a {@link BufferedImage} for each of a predefined set
- * of <em>screen scales</em> (a screen scale of 1 means that 1 pixel in the
- * screen image is displayed as 1 pixel on the canvas, a screen scale of 0.5
- * means 1 pixel in the screen image is displayed as 2 pixel on the canvas,
- * etc.)
+ * When drawing the low-resolution target images to the screen, they will be
+ * scaled up by Java2D (or JavaFX, etc) to the full canvas size, which is
+ * relatively fast. Rendering the small, low-resolution images is usually very
+ * fast, such that the display is very interactive while the user changes the
+ * viewing transformation for example. When the transformation remains fixed for
+ * a longer period, higher-resolution details are filled in successively.
  * <p>
- * At any time, one of these screen scales is selected as the
- * <em>highest screen scale</em>. Rendering starts with this highest screen
- * scale and then proceeds to lower screen scales (higher resolution images).
- * Unless the highest screen scale is currently rendering,
- * {@link #requestRepaint() repaint request} will cancel rendering, such that
- * display remains interactive.
+ * The renderer allocates a {@code RenderResult} for each of a predefined set of
+ * <em>screen scales</em> (a screen scale of 1 means that 1 pixel in the screen
+ * image is displayed as 1 pixel on the canvas, a screen scale of 0.5 means 1
+ * pixel in the screen image is displayed as 2 pixel on the canvas, etc.)
  * <p>
- * The renderer tries to maintain a per-frame rendering time close to a desired
- * number of <code>targetRenderNanos</code> nanoseconds. If the rendering time
- * (in nanoseconds) for the (currently) highest scaled screen image is above
- * this threshold, a coarser screen scale is chosen as the highest screen scale
- * to use. Similarly, if the rendering time for the (currently) second-highest
- * scaled screen image is below this threshold, this finer screen scale chosen
- * as the highest screen scale to use.
+ * At any time, one of these screen scales is selected as the <em>highest screen
+ * scale</em>. Rendering starts with this highest screen scale and then proceeds
+ * to lower screen scales (higher resolution images). Unless the highest screen
+ * scale is currently rendering, {@link #requestRepaint() repaint request} will
+ * cancel rendering, such that display remains interactive.
  * <p>
- * The renderer uses multiple threads (if desired) and double-buffering (if
- * desired).
+ * The renderer tries to maintain a per-frame rendering time close to
+ * {@code targetRenderNanos} nanoseconds. The current highest screen scale is
+ * chosen to match this time based on per-output-pixel time measured in previous
+ * frames.
  * <p>
- * Double buffering means that three {@link BufferedImage BufferedImages} are
- * created for every screen scale. After rendering the first one of them and
- * setting it to the {@link RenderTarget}, next time, rendering goes to the
- * second one, then to the third. The {@link RenderTarget} will always have a
- * complete image, which is not rendered to while it is potentially drawn to the
- * screen. When setting an image to the {@link RenderTarget}, the
- * {@link RenderTarget} will release one of the previously set images to be
- * rendered again. Thus, rendering will not interfere with painting the
- * {@link BufferedImage} to the canvas.
+ * The renderer uses multiple threads (if desired).
  * <p>
  * The renderer supports rendering of {@link Volatile} sources. In each
  * rendering pass, all currently valid data for the best fitting mipmap level
- * and all coarser levels is rendered to a {@link #renderImages temporary image}
- * for each visible source. Then the temporary images are combined to the final
- * image for display. The number of passes required until all data is valid
- * might differ between visible sources.
+ * and all coarser levels is rendered to a temporary image for each visible
+ * source. Then the temporary images are combined to the final image for
+ * display. The number of passes required until all data is valid might differ
+ * between visible sources.
  * <p>
- * Rendering timing is tied to a {@link CacheControl} control for IO budgeting, etc.
+ * Rendering timing is tied to a {@link CacheControl} control for IO budgeting,
+ * etc.
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
 public class MultiResolutionRenderer
 {
 	/**
-	 * Receiver for the {@link BufferedImage BufferedImages} that we render.
+	 * Receiver for the {@code BufferedImage BufferedImages} that we render.
 	 */
-	protected final TransformAwareRenderTarget display;
+	private final RenderTarget< ? > display;
 
 	/**
 	 * Thread that triggers repainting of the display.
 	 * Requests for repainting are send there.
 	 */
-	protected final PainterThread painterThread;
+	private final RequestRepaint painterThread;
 
 	/**
-	 * Currently active projector, used to re-paint the display. It maps the
-	 * source data to {@link #screenImages}.
+	 * Creates projectors for rendering current {@code ViewerState} to a
+	 * {@code screenImage}.
 	 */
-	protected VolatileProjector projector;
+	private final ProjectorFactory projectorFactory;
 
 	/**
-	 * The index of the screen scale of the {@link #projector current projector}.
+	 * Controls IO budgeting and fetcher queue.
 	 */
-	protected int currentScreenScaleIndex;
+	private final CacheControl cacheControl;
 
-	/**
-	 * Whether double buffering is used.
-	 */
-	protected final boolean doubleBuffered;
+	// TODO: should be settable
+	private final long[] iobudget = new long[] { 100l * 1000000l,  10l * 1000000l };
 
 	/**
-	 * Double-buffer index of next {@link #screenImages image} to render.
+	 * Maintains current sizes and transforms at every screen scale level.
+	 * Records interval rendering requests.
 	 */
-	protected final ArrayDeque< Integer > renderIdQueue;
+	private final ScreenScales screenScales;
 
 	/**
-	 * Maps from {@link BufferedImage} to double-buffer index.
-	 * Needed for double-buffering.
+	 * Maintains arrays for intermediate per-source render images and masks.
 	 */
-	protected final HashMap< BufferedImage, Integer > bufferedImageToRenderId;
+	private final RenderStorage renderStorage;
 
 	/**
-	 * Used to render an individual source. One image per screen resolution and
-	 * visible source. First index is screen scale, second index is index in
-	 * list of visible sources.
+	 * Estimate of the time it takes to render one screen pixel from one source,
+	 * in nanoseconds.
 	 */
-	protected ARGBScreenImage[][] renderImages;
+	private final MovingAverage renderNanosPerPixelAndSource;
 
 	/**
-	 * Storage for mask images of {@link VolatileHierarchyProjector}.
-	 * One array per visible source. (First) index is index in list of visible sources.
+	 * Currently active projector, used to re-paint the display. It maps the
+	 * source data to {@code ©screenImages}. {@code projector.cancel()} can be
+	 * used to cancel the ongoing rendering operation.
 	 */
-	protected byte[][] renderMaskArrays;
+	private VolatileProjector projector;
 
 	/**
-	 * Used to render the image for display. Three images per screen resolution
-	 * if double buffering is enabled. First index is screen scale, second index
-	 * is double-buffer.
+	 * Whether the current rendering operation may be cancelled (to start a new
+	 * one). Rendering may be cancelled unless we are rendering at the
+	 * (estimated) coarsest screen scale meeting the rendering time threshold.
 	 */
-	protected ARGBScreenImage[][] screenImages;
+	private boolean renderingMayBeCancelled;
 
 	/**
-	 * {@link BufferedImage}s wrapping the data in the {@link #screenImages}.
-	 * First index is screen scale, second index is double-buffer.
+	 * Snapshot of the ViewerState that is currently being rendered.
+	 * A new snapshot is taken in the first {@code paint()} pass after a (full frame) {@link #requestRepaint()}
 	 */
-	protected BufferedImage[][] bufferedImages;
+	private ViewerState currentViewerState;
 
 	/**
-	 * Scale factors from the {@link #display viewer canvas} to the
-	 * {@link #screenImages}.
-	 *
-	 * A scale factor of 1 means 1 pixel in the screen image is displayed as 1
-	 * pixel on the canvas, a scale factor of 0.5 means 1 pixel in the screen
-	 * image is displayed as 2 pixel on the canvas, etc.
+	 * The sources that are actually visible on screen currently. This means
+	 * that the sources both are visible in the {@link #currentViewerState} (via
+	 * {@link ViewerState#getVisibleAndPresentSources()
+	 * getVisibleAndPresentSources}) and, when transformed to viewer
+	 * coordinates, overlap the screen area ({@link #display}).
 	 */
-	protected final double[] screenScales;
+	private final List< SourceAndConverter< ? > > currentVisibleSourcesOnScreen;
+
 
 	/**
-	 * The scale transformation from viewer to {@link #screenImages screen
-	 * image}. Each transformations corresponds to a {@link #screenScales screen
-	 * scale}.
+	 * The last successfully rendered (not cancelled) full frame result.
+	 * This result is by full frame refinement passes and/or interval rendering passes.
 	 */
-	protected AffineTransform3D[] screenScaleTransforms;
+	private RenderResult currentRenderResult;
 
 	/**
-	 * If the rendering time (in nanoseconds) for the (currently) highest scaled
-	 * screen image is above this threshold, increase the
-	 * {@link #maxScreenScaleIndex index} of the highest screen scale to use.
-	 * Similarly, if the rendering time for the (currently) second-highest
-	 * scaled screen image is below this threshold, decrease the
-	 * {@link #maxScreenScaleIndex index} of the highest screen scale to use.
+	 * If {@code true}, then we are painting intervals currently.
+	 * If {@code false}, then we are painting full frames.
 	 */
-	protected final long targetRenderNanos;
+	private boolean intervalMode;
 
-	/**
-	 * The index of the (coarsest) screen scale with which to start rendering.
-	 * Once this level is painted, rendering proceeds to lower screen scales
-	 * until index 0 (full resolution) has been reached. While rendering, the
-	 * maxScreenScaleIndex is adapted such that it is the highest index for
-	 * which rendering in {@link #targetRenderNanos} nanoseconds is still
-	 * possible.
+	/*
+	 *
+	 * === FULL FRAME RENDERING ===
+	 *
 	 */
-	protected int maxScreenScaleIndex;
 
 	/**
-	 * The index of the screen scale which should be rendered next.
+	 * Screen scale of the last successful (not cancelled) rendering pass in
+	 * full-frame mode.
 	 */
-	protected int requestedScreenScaleIndex;
+	private int currentScreenScaleIndex;
 
 	/**
-	 * Whether the current rendering operation may be cancelled (to start a
-	 * new one). Rendering may be cancelled unless we are rendering at
-	 * coarsest screen scale and coarsest mipmap level.
+	 * The index of the screen scale which should be rendered next in full-frame
+	 * mode.
 	 */
-	protected volatile boolean renderingMayBeCancelled;
+	private int requestedScreenScaleIndex;
 
 	/**
-	 * How many threads to use for rendering.
+	 * Whether a full frame repaint was {@link #requestRepaint() requested}.
+	 * Supersedes {@link #newIntervalRequest}.
 	 */
-	protected final int numRenderingThreads;
+	private boolean newFrameRequest;
 
-	/**
-	 * {@link ExecutorService} used for rendering.
+	/*
+	 *
+	 * === INTERVAL RENDERING ===
+	 *
 	 */
-	protected final ExecutorService renderingExecutorService;
 
 	/**
-	 * TODO
+	 * Screen scale of the last successful (not cancelled) rendering pass in
+	 * interval mode.
 	 */
-	protected final AccumulateProjectorFactory< ARGBType > accumulateProjectorFactory;
+	private int currentIntervalScaleIndex;
 
 	/**
-	 * Controls IO budgeting and fetcher queue.
+	 * The index of the screen scale which should be rendered next in interval
+	 * mode.
 	 */
-	protected final CacheControl cacheControl;
+	private int requestedIntervalScaleIndex;
 
 	/**
-	 * Whether volatile versions of sources should be used if available.
+	 * Whether repainting of an interval was {@link #requestRepaint(Interval)
+	 * requested}. The union of all pending intervals are recorded in
+	 * {@link #screenScales}. Pending interval requests are obsoleted by full
+	 * frame repaints requests ({@link #newFrameRequest}).
 	 */
-	protected final boolean useVolatileIfAvailable;
+	private boolean newIntervalRequest;
 
 	/**
-	 * Whether a repaint was {@link #requestRepaint() requested}. This will
-	 * cause {@link CacheControl#prepareNextFrame()}.
+	 * Re-used for all interval rendering.
 	 */
-	protected boolean newFrameRequest;
+	private final RenderResult intervalResult;
 
 	/**
-	 * The timepoint for which last a projector was
-	 * {@link #createProjector(ViewerState, ARGBScreenImage) created}.
+	 * Currently rendering interval. This is pulled from {@link #screenScales}
+	 * at the start of {@code paint()}, which clears requested intervals at
+	 * {@link #currentIntervalScaleIndex} or coarser. (This ensures that new
+	 * interval requests arriving during rendering are not missed. If the
+	 * requested intervals would be cleared after rendering, this might happen.
+	 * Instead we re-request the pulled intervals, if rendering fails.)
 	 */
-	protected int previousTimepoint;
-
-	// TODO: should be settable
-	protected long[] iobudget = new long[] { 100l * 1000000l,  10l * 1000000l };
-
-	// TODO: should be settable
-	protected boolean prefetchCells = true;
+	private IntervalRenderData intervalRenderData;
 
 	/**
 	 * @param display
@@ -289,7 +247,7 @@ public class MultiResolutionRenderer
 	 * @param painterThread
 	 *            Thread that triggers repainting of the display. Requests for
 	 *            repainting are send there.
-	 * @param screenScales
+	 * @param screenScaleFactors
 	 *            Scale factors from the viewer canvas to screen images of
 	 *            different resolutions. A scale factor of 1 means 1 pixel in
 	 *            the screen image is displayed as 1 pixel on the canvas, a
@@ -298,8 +256,6 @@ public class MultiResolutionRenderer
 	 * @param targetRenderNanos
 	 *            Target rendering time in nanoseconds. The rendering time for
 	 *            the coarsest rendered scale should be below this threshold.
-	 * @param doubleBuffered
-	 *            Whether to use double buffered rendering.
 	 * @param numRenderingThreads
 	 *            How many threads to use for rendering.
 	 * @param renderingExecutorService
@@ -316,194 +272,249 @@ public class MultiResolutionRenderer
 	 *            the cache controls IO budgeting and fetcher queue.
 	 */
 	public MultiResolutionRenderer(
-			final RenderTarget display,
-			final PainterThread painterThread,
-			final double[] screenScales,
+			final RenderTarget< ? > display,
+			final RequestRepaint painterThread,
+			final double[] screenScaleFactors,
 			final long targetRenderNanos,
-			final boolean doubleBuffered,
 			final int numRenderingThreads,
 			final ExecutorService renderingExecutorService,
 			final boolean useVolatileIfAvailable,
 			final AccumulateProjectorFactory< ARGBType > accumulateProjectorFactory,
 			final CacheControl cacheControl )
 	{
-		this.display = wrapTransformAwareRenderTarget( display );
+		this.display = display;
 		this.painterThread = painterThread;
 		projector = null;
 		currentScreenScaleIndex = -1;
-		this.screenScales = screenScales.clone();
-		this.doubleBuffered = doubleBuffered;
-		renderIdQueue = new ArrayDeque<>();
-		bufferedImageToRenderId = new HashMap<>();
-		renderImages = new ARGBScreenImage[ screenScales.length ][ 0 ];
-		renderMaskArrays = new byte[ 0 ][];
-		screenImages = new ARGBScreenImage[ screenScales.length ][ 3 ];
-		bufferedImages = new BufferedImage[ screenScales.length ][ 3 ];
-		screenScaleTransforms = new AffineTransform3D[ screenScales.length ];
-
-		this.targetRenderNanos = targetRenderNanos;
-
-		maxScreenScaleIndex = screenScales.length - 1;
-		requestedScreenScaleIndex = maxScreenScaleIndex;
-		renderingMayBeCancelled = true;
-		this.numRenderingThreads = numRenderingThreads;
-		this.renderingExecutorService = renderingExecutorService;
-		this.useVolatileIfAvailable = useVolatileIfAvailable;
-		this.accumulateProjectorFactory = accumulateProjectorFactory;
+		currentVisibleSourcesOnScreen = new ArrayList<>();
+		screenScales = new ScreenScales( screenScaleFactors, targetRenderNanos );
+		renderStorage = new RenderStorage();
+
+		renderNanosPerPixelAndSource = new MovingAverage( 3 );
+		renderNanosPerPixelAndSource.init( 500 );
+
+		requestedScreenScaleIndex = screenScales.size() - 1;
+		renderingMayBeCancelled = false;
 		this.cacheControl = cacheControl;
 		newFrameRequest = false;
-		previousTimepoint = -1;
+
+		intervalResult = display.createRenderResult();
+
+		projectorFactory = new ProjectorFactory(
+				numRenderingThreads,
+				renderingExecutorService,
+				useVolatileIfAvailable,
+				accumulateProjectorFactory );
 	}
 
 	/**
-	 * Check whether the size of the display component was changed and
-	 * recreate {@link #screenImages} and {@link #screenScaleTransforms} accordingly.
-	 *
-	 * @return whether the size was changed.
+	 * Request a repaint of the display from the painter thread. The painter
+	 * thread will trigger a {@link #paint} as soon as possible (that is,
+	 * immediately or after the currently running {@link #paint} has completed).
 	 */
-	protected synchronized boolean checkResize()
+	public synchronized void requestRepaint()
 	{
-		final int componentW = display.getWidth();
-		final int componentH = display.getHeight();
-		if ( screenImages[ 0 ][ 0 ] == null || screenImages[ 0 ][ 0 ].dimension( 0 ) != ( int ) ( componentW * screenScales[ 0 ] ) || screenImages[ 0 ][ 0 ].dimension( 1 ) != ( int ) ( componentH  * screenScales[ 0 ] ) )
-		{
-			renderIdQueue.clear();
-			renderIdQueue.addAll( Arrays.asList( 0, 1, 2 ) );
-			bufferedImageToRenderId.clear();
-			for ( int i = 0; i < screenScales.length; ++i )
-			{
-				final double screenToViewerScale = screenScales[ i ];
-				final int w = ( int ) ( screenToViewerScale * componentW );
-				final int h = ( int ) ( screenToViewerScale * componentH );
-				if ( doubleBuffered )
-				{
-					for ( int b = 0; b < 3; ++b )
-					{
-						// reuse storage arrays of level 0 (highest resolution)
-						screenImages[ i ][ b ] = ( i == 0 ) ?
-								new ARGBScreenImage( w, h ) :
-								new ARGBScreenImage( w, h, screenImages[ 0 ][ b ].getData() );
-						final BufferedImage bi = GuiUtil.getBufferedImage( screenImages[ i ][ b ] );
-						bufferedImages[ i ][ b ] = bi;
-						bufferedImageToRenderId.put( bi, b );
-					}
-				}
-				else
-				{
-					screenImages[ i ][ 0 ] = new ARGBScreenImage( w, h );
-					bufferedImages[ i ][ 0 ] = GuiUtil.getBufferedImage( screenImages[ i ][ 0 ] );
-				}
-				final AffineTransform3D scale = new AffineTransform3D();
-				final double xScale = ( double ) w / componentW;
-				final double yScale = ( double ) h / componentH;
-				scale.set( xScale, 0, 0 );
-				scale.set( yScale, 1, 1 );
-				scale.set( 0.5 * xScale - 0.5, 0, 3 );
-				scale.set( 0.5 * yScale - 0.5, 1, 3 );
-				screenScaleTransforms[ i ] = scale;
-			}
-
-			return true;
-		}
-		return false;
+		if ( renderingMayBeCancelled && projector != null )
+			projector.cancel();
+		newFrameRequest = true;
+		painterThread.requestRepaint();
 	}
 
-	protected boolean checkRenewRenderImages( final int numVisibleSources )
+	/**
+	 * Request a repaint of the given {@code interval} of the display from the
+	 * painter thread. The painter thread will trigger a {@link #paint} as soon
+	 * as possible (that is, immediately or after the currently running
+	 * {@link #paint} has completed).
+	 */
+	public synchronized void requestRepaint( final Interval interval )
 	{
-		final int n = numVisibleSources > 1 ? numVisibleSources : 0;
-		if ( n != renderImages[ 0 ].length ||
-				( n != 0 &&
-					( renderImages[ 0 ][ 0 ].dimension( 0 ) != screenImages[ 0 ][ 0 ].dimension( 0 ) ||
-					  renderImages[ 0 ][ 0 ].dimension( 1 ) != screenImages[ 0 ][ 0 ].dimension( 1 ) ) ) )
+		// if interval doesn't overlap the screen, do nothing
+		if ( Intervals.isEmpty( screenScales.clipToScreen( interval ) ) )
+			return;
+
+		if ( !intervalMode && !renderingMayBeCancelled )
 		{
-			renderImages = new ARGBScreenImage[ screenScales.length ][ n ];
-			for ( int i = 0; i < screenScales.length; ++i )
-			{
-				final int w = ( int ) screenImages[ i ][ 0 ].dimension( 0 );
-				final int h = ( int ) screenImages[ i ][ 0 ].dimension( 1 );
-				for ( int j = 0; j < n; ++j )
-				{
-					renderImages[ i ][ j ] = ( i == 0 ) ?
-						new ARGBScreenImage( w, h ) :
-						new ARGBScreenImage( w, h, renderImages[ 0 ][ j ].getData() );
-				}
-			}
-			return true;
+			/*
+			 * We are currently rendering a full frame at the coarsest
+			 * resolution. There is no point in painting an interval now. Just
+			 * request a new full frame.
+			 */
+			newFrameRequest = true;
 		}
-		return false;
-	}
-
-	protected boolean checkRenewMaskArrays( final int numVisibleSources )
-	{
-		if ( numVisibleSources != renderMaskArrays.length ||
-				( numVisibleSources != 0 &&	( renderMaskArrays[ 0 ].length < screenImages[ 0 ][ 0 ].size() ) ) )
+		else
 		{
-			final int size = ( int ) screenImages[ 0 ][ 0 ].size();
-			renderMaskArrays = new byte[ numVisibleSources ][];
-			for ( int j = 0; j < numVisibleSources; ++j )
-				renderMaskArrays[ j ] = new byte[ size ];
-			return true;
+			if ( renderingMayBeCancelled && projector != null )
+				projector.cancel();
+			screenScales.requestInterval( interval );
+			newIntervalRequest = true;
 		}
-		return false;
+		painterThread.requestRepaint();
 	}
 
-	protected final AffineTransform3D currentProjectorTransform = new AffineTransform3D();
+	/**
+	 * DON'T USE THIS.
+	 * <p>
+	 * This is a work around for JDK bug
+	 * https://bugs.openjdk.java.net/browse/JDK-8029147 which leads to
+	 * ViewerPanel not being garbage-collected when ViewerFrame is closed. So
+	 * instead we need to manually let go of resources...
+	 */
+	public void kill()
+	{
+		projector = null;
+		currentViewerState = null;
+		currentRenderResult = null;
+		currentVisibleSourcesOnScreen.clear();
+		renderStorage.clear();
+	}
 
 	/**
 	 * Render image at the {@link #requestedScreenScaleIndex requested screen
 	 * scale}.
 	 */
-	public boolean paint( final ViewerState state )
+	public boolean paint( final ViewerState viewerState )
 	{
-		if ( display.getWidth() <= 0 || display.getHeight() <= 0 )
+		final int screenW = display.getWidth();
+		final int screenH = display.getHeight();
+		if ( screenW <= 0 || screenH <= 0 )
 			return false;
 
-		final boolean resized = checkResize();
+		final boolean newFrame;
+		final boolean newInterval;
+		final boolean prepareNextFrame;
+		final boolean createProjector;
+		synchronized ( this )
+		{
+			final boolean resized = screenScales.checkResize( screenW, screenH );
+
+			newFrame = newFrameRequest || resized;
+			if ( newFrame )
+			{
+				intervalMode = false;
+				screenScales.clearRequestedIntervals();
+			}
+
+			newInterval = newIntervalRequest && !newFrame;
+			if ( newInterval )
+			{
+				intervalMode = true;
+				final int numSources = currentVisibleSourcesOnScreen.size();
+				final double renderNanosPerPixel = renderNanosPerPixelAndSource.getAverage() * numSources;
+				requestedIntervalScaleIndex = screenScales.suggestIntervalScreenScale( renderNanosPerPixel, currentScreenScaleIndex );
+			}
+
+			prepareNextFrame = newFrame || newInterval;
+			renderingMayBeCancelled = !prepareNextFrame;
 
-		// the BufferedImage that is rendered to (to paint to the canvas)
-		final BufferedImage bufferedImage;
+			if ( intervalMode )
+			{
+				createProjector = newInterval || ( requestedIntervalScaleIndex != currentIntervalScaleIndex );
+				if ( createProjector )
+					intervalRenderData = screenScales.pullIntervalRenderData( requestedIntervalScaleIndex, currentScreenScaleIndex );
+			}
+			else
+				createProjector = newFrame || ( requestedScreenScaleIndex != currentScreenScaleIndex );
+
+			newFrameRequest = false;
+			newIntervalRequest = false;
+		}
 
+		if ( prepareNextFrame )
+			cacheControl.prepareNextFrame();
+
+		if ( newFrame )
+		{
+			currentViewerState = viewerState.snapshot();
+			VisibilityUtils.computeVisibleSourcesOnScreen( currentViewerState, screenScales.get( 0 ), currentVisibleSourcesOnScreen );
+			final int numSources = currentVisibleSourcesOnScreen.size();
+			final double renderNanosPerPixel = renderNanosPerPixelAndSource.getAverage() * numSources;
+			requestedScreenScaleIndex = screenScales.suggestScreenScale( renderNanosPerPixel );
+		}
+
+		if ( !intervalMode && requestedScreenScaleIndex < 0 )
+			return true;
+
+		return intervalMode
+				? paintInterval( createProjector )
+				: paintFullFrame( createProjector );
+	}
+
+	private boolean paintFullFrame( final boolean createProjector )
+	{
 		// the projector that paints to the screenImage.
 		final VolatileProjector p;
 
-		final boolean clearQueue;
+		// holds new RenderResult, in case that a new projector is created in full frame mode
+		RenderResult renderResult = null;
 
-		final boolean createProjector;
+		// whether to request a newFrame, in case that a new projector is created in full frame mode
+		boolean requestNewFrameIfIncomplete = false;
 
 		synchronized ( this )
 		{
-			// Rendering may be cancelled unless we are rendering at coarsest
-			// screen scale and coarsest mipmap level.
-			renderingMayBeCancelled = ( requestedScreenScaleIndex < maxScreenScaleIndex );
-
-			clearQueue = newFrameRequest;
-			if ( clearQueue )
-				cacheControl.prepareNextFrame();
-			createProjector = newFrameRequest || resized || ( requestedScreenScaleIndex != currentScreenScaleIndex );
-			newFrameRequest = false;
-
 			if ( createProjector )
 			{
-				final int renderId = renderIdQueue.peek();
+				final ScreenScale screenScale = screenScales.get( requestedScreenScaleIndex );
+
+				renderResult = display.getReusableRenderResult();
+				renderResult.init( screenScale.width(), screenScale.height() );
+				renderResult.setScaleFactor( screenScale.scale() );
+				currentViewerState.getViewerTransform( renderResult.getViewerTransform() );
+
+				renderStorage.checkRenewData( screenScales.get( 0 ).width(), screenScales.get( 0 ).height(), currentVisibleSourcesOnScreen.size() );
+				projector = createProjector( currentViewerState, currentVisibleSourcesOnScreen, requestedScreenScaleIndex, renderResult.getTargetImage(), 0, 0 );
+				requestNewFrameIfIncomplete = projectorFactory.requestNewFrameIfIncomplete();
+			}
+			p = projector;
+		}
+
+		// try rendering
+		final boolean success = p.map( createProjector );
+		final long rendertime = p.getLastFrameRenderNanoTime();
+
+		synchronized ( this )
+		{
+			// if rendering was not cancelled...
+			if ( success )
+			{
 				currentScreenScaleIndex = requestedScreenScaleIndex;
-				bufferedImage = bufferedImages[ currentScreenScaleIndex ][ renderId ];
-				final ARGBScreenImage screenImage = screenImages[ currentScreenScaleIndex ][ renderId ];
-				synchronized ( state )
+				if ( createProjector )
 				{
-					final int numVisibleSources = state.getVisibleSourceIndices().size();
-					checkRenewRenderImages( numVisibleSources );
-					checkRenewMaskArrays( numVisibleSources );
-					p = createProjector( state, screenImage );
+					renderResult.setUpdated();
+					( ( RenderTarget ) display ).setRenderResult( renderResult );
+					currentRenderResult = renderResult;
+					recordRenderTime( renderResult, rendertime );
 				}
-				projector = p;
+				else
+					currentRenderResult.setUpdated();
+
+				if ( !p.isValid() && requestNewFrameIfIncomplete )
+					requestRepaint();
+				else if ( p.isValid() && currentScreenScaleIndex == 0 )
+					// indicate that rendering is complete
+					requestedScreenScaleIndex = -1;
+				else
+					iterateRepaint( Math.max( 0, currentScreenScaleIndex - 1 ) );
 			}
-			else
+		}
+
+		return success;
+	}
+
+	private boolean paintInterval( final boolean createProjector )
+	{
+		// the projector that paints to the screenImage.
+		final VolatileProjector p;
+
+		synchronized ( this )
+		{
+			if ( createProjector )
 			{
-				bufferedImage = null;
-				p = projector;
+				intervalResult.init( intervalRenderData.width(), intervalRenderData.height() );
+				intervalResult.setScaleFactor( intervalRenderData.scale() );
+				projector = createProjector( currentViewerState, currentVisibleSourcesOnScreen, requestedIntervalScaleIndex, intervalResult.getTargetImage(), intervalRenderData.offsetX(), intervalRenderData.offsetY() );
 			}
-
-			requestedScreenScaleIndex = 0;
+			p = projector;
 		}
 
 		// try rendering
@@ -515,357 +526,112 @@ public class MultiResolutionRenderer
 			// if rendering was not cancelled...
 			if ( success )
 			{
-				if ( createProjector )
-				{
-					final BufferedImage bi = display.setBufferedImageAndTransform( bufferedImage, currentProjectorTransform );
-					if ( doubleBuffered )
-					{
-						renderIdQueue.pop();
-						final Integer id = bufferedImageToRenderId.get( bi );
-						if ( id != null )
-							renderIdQueue.add( id );
-					}
+				currentIntervalScaleIndex = requestedIntervalScaleIndex;
+				currentRenderResult.patch( intervalResult, intervalRenderData.targetInterval(), intervalRenderData.tx(), intervalRenderData.ty() );
 
-					if ( currentScreenScaleIndex == maxScreenScaleIndex )
-					{
-						if ( rendertime > targetRenderNanos && maxScreenScaleIndex < screenScales.length - 1 )
-							maxScreenScaleIndex++;
-						else if ( rendertime < targetRenderNanos / 3 && maxScreenScaleIndex > 0 )
-							maxScreenScaleIndex--;
-					}
-					else if ( currentScreenScaleIndex == maxScreenScaleIndex - 1 )
-					{
-						if ( rendertime < targetRenderNanos && maxScreenScaleIndex > 0 )
-							maxScreenScaleIndex--;
-					}
-//					System.out.println( String.format( "rendering:%4d ms", rendertime / 1000000 ) );
-//					System.out.println( "scale = " + currentScreenScaleIndex );
-//					System.out.println( "maxScreenScaleIndex = " + maxScreenScaleIndex + "  (" + screenImages[ maxScreenScaleIndex ][ 0 ].dimension( 0 ) + " x " + screenImages[ maxScreenScaleIndex ][ 0 ].dimension( 1 ) + ")" );
-				}
+				if ( createProjector )
+					recordRenderTime( intervalResult, rendertime );
 
-				if ( currentScreenScaleIndex > 0 )
-					requestRepaint( currentScreenScaleIndex - 1 );
-				else if ( !p.isValid() )
+				if ( currentIntervalScaleIndex > currentScreenScaleIndex )
+					iterateRepaintInterval( currentIntervalScaleIndex - 1 );
+				else if ( p.isValid() )
 				{
-					try
+					// if full frame rendering was not yet complete
+					if ( requestedScreenScaleIndex >= 0 )
 					{
-						Thread.sleep( 1 );
+						// go back to full frame rendering
+						intervalMode = false;
+						if ( requestedScreenScaleIndex == currentScreenScaleIndex )
+							++currentScreenScaleIndex;
+						painterThread.requestRepaint();
 					}
-					catch ( final InterruptedException e )
-					{
-						// restore interrupted state
-						Thread.currentThread().interrupt();
-					}
-					requestRepaint( currentScreenScaleIndex );
 				}
+				else
+					iterateRepaintInterval( currentIntervalScaleIndex );
 			}
+			// if rendering was cancelled...
+			else
+				intervalRenderData.reRequest();
 		}
 
 		return success;
 	}
 
-	/**
-	 * Request a repaint of the display from the painter thread, with maximum
-	 * screen scale index and mipmap level.
-	 */
-	public synchronized void requestRepaint()
+	private void recordRenderTime( final RenderResult result, final long renderNanos )
 	{
-		newFrameRequest = true;
-		requestRepaint( maxScreenScaleIndex );
+		final int numSources = currentVisibleSourcesOnScreen.size();
+		final int numRenderPixels = ( int ) Intervals.numElements( result.getTargetImage() ) * numSources;
+		if ( numRenderPixels >= 4096 )
+			renderNanosPerPixelAndSource.add( renderNanos / ( double ) numRenderPixels );
 	}
 
 	/**
-	 * Request a repaint of the display from the painter thread. The painter
-	 * thread will trigger a {@link #paint(ViewerState)} as soon as possible (that is,
-	 * immediately or after the currently running {@link #paint(ViewerState)} has
-	 * completed).
+	 * Request iterated repaint at the specified {@code screenScaleIndex}. This
+	 * is used to repaint the {@code currentViewerState} in a loop, until
+	 * everything is painted at highest resolution from valid data (or until
+	 * painting is interrupted by a new request}.
 	 */
-	public synchronized void requestRepaint( final int screenScaleIndex )
+	private void iterateRepaint( final int screenScaleIndex )
 	{
-		if ( renderingMayBeCancelled && projector != null )
-			projector.cancel();
-		if ( screenScaleIndex > requestedScreenScaleIndex )
-			requestedScreenScaleIndex = screenScaleIndex;
+		if ( screenScaleIndex == currentScreenScaleIndex )
+			usleep();
+		requestedScreenScaleIndex = screenScaleIndex;
 		painterThread.requestRepaint();
 	}
 
 	/**
-	 * DON'T USE THIS.
-	 * <p>
-	 * This is a work around for JDK bug
-	 * https://bugs.openjdk.java.net/browse/JDK-8029147 which leads to
-	 * ViewerPanel not being garbage-collected when ViewerFrame is closed. So
-	 * instead we need to manually let go of resources...
+	 * Request iterated repaint at the specified {@code intervalScaleIndex}.
+	 * This is used to repaint the current interval in a loop, until everything
+	 * is painted at highest resolution from valid data (or until painting is
+	 * interrupted by a new request}.
 	 */
-	public void kill()
-	{
-		if ( display instanceof TransformAwareBufferedImageOverlayRenderer )
-			( ( TransformAwareBufferedImageOverlayRenderer ) display ).kill();
-		projector = null;
-		renderIdQueue.clear();
-		bufferedImageToRenderId.clear();
-		for ( int i = 0; i < renderImages.length; ++i )
-			renderImages[ i ] = null;
-		for ( int i = 0; i < renderMaskArrays.length; ++i )
-			renderMaskArrays[ i ] = null;
-		for ( int i = 0; i < screenImages.length; ++i )
-			screenImages[ i ] = null;
-		for ( int i = 0; i < bufferedImages.length; ++i )
-			bufferedImages[ i ] = null;
-	}
-
-	private VolatileProjector createProjector(
-			final ViewerState viewerState,
-			final ARGBScreenImage screenImage )
+	private void iterateRepaintInterval( final int intervalScaleIndex )
 	{
-		/*
-		 * This shouldn't be necessary, with
-		 * CacheHints.LoadingStrategy==VOLATILE
-		 */
-//		CacheIoTiming.getIoTimeBudget().clear(); // clear time budget such that prefetching doesn't wait for loading blocks.
-		final List< SourceState< ? > > sourceStates = viewerState.getSources();
-		final List< Integer > visibleSourceIndices = viewerState.getVisibleSourceIndices();
-		VolatileProjector projector;
-		if ( visibleSourceIndices.isEmpty() )
-			projector = new EmptyProjector<>( screenImage );
-		else if ( visibleSourceIndices.size() == 1 )
-		{
-			final int i = visibleSourceIndices.get( 0 );
-			projector = createSingleSourceProjector( viewerState, sourceStates.get( i ), i, currentScreenScaleIndex, screenImage, renderMaskArrays[ 0 ] );
-		}
-		else
+		if ( intervalScaleIndex == currentIntervalScaleIndex )
 		{
-			final ArrayList< VolatileProjector > sourceProjectors = new ArrayList<>();
-			final ArrayList< ARGBScreenImage > sourceImages = new ArrayList<>();
-			final ArrayList< Source< ? > > sources = new ArrayList<>();
-			int j = 0;
-			for ( final int i : visibleSourceIndices )
-			{
-				final ARGBScreenImage renderImage = renderImages[ currentScreenScaleIndex ][ j ];
-				final byte[] maskArray = renderMaskArrays[ j ];
-				++j;
-				final VolatileProjector p = createSingleSourceProjector(
-						viewerState, sourceStates.get( i ), i, currentScreenScaleIndex,
-						renderImage, maskArray );
-				sourceProjectors.add( p );
-				sources.add( sourceStates.get( i ).getSpimSource() );
-				sourceImages.add( renderImage );
-			}
-			projector = accumulateProjectorFactory.createAccumulateProjector( sourceProjectors, sources, sourceImages, screenImage, numRenderingThreads, renderingExecutorService );
+			intervalRenderData.reRequest();
+			usleep();
 		}
-		previousTimepoint = viewerState.getCurrentTimepoint();
-		viewerState.getViewerTransform( currentProjectorTransform );
-		CacheIoTiming.getIoTimeBudget().reset( iobudget );
-		return projector;
+		requestedIntervalScaleIndex = intervalScaleIndex;
+		painterThread.requestRepaint();
 	}
 
-	private static class SimpleVolatileProjector< A, B > extends SimpleInterruptibleProjector< A, B > implements VolatileProjector
+	/**
+	 * Wait for 1ms so that fetcher threads get a chance to do work.
+	 */
+	private void usleep()
 	{
-		private boolean valid = false;
-
-		public SimpleVolatileProjector(
-				final RandomAccessible< A > source,
-				final Converter< ? super A, B > converter,
-				final RandomAccessibleInterval< B > target,
-				final int numThreads,
-				final ExecutorService executorService )
-		{
-			super( source, converter, target, numThreads, executorService );
-		}
-
-		@Override
-		public boolean map( final boolean clearUntouchedTargetPixels )
+		try
 		{
-			final boolean success = super.map();
-			valid |= success;
-			return success;
+			Thread.sleep( 1 );
 		}
-
-		@Override
-		public boolean isValid()
-		{
-			return valid;
-		}
-	}
-
-	private < T > VolatileProjector createSingleSourceProjector(
-			final ViewerState viewerState,
-			final SourceState< T > source,
-			final int sourceIndex,
-			final int screenScaleIndex,
-			final ARGBScreenImage screenImage,
-			final byte[] maskArray )
-	{
-		if ( useVolatileIfAvailable )
+		catch ( final InterruptedException e )
 		{
-			if ( source.asVolatile() != null )
-				return createSingleSourceVolatileProjector( viewerState, source.asVolatile(), sourceIndex, screenScaleIndex, screenImage, maskArray );
-			else if ( source.getSpimSource().getType() instanceof Volatile )
-			{
-				@SuppressWarnings( "unchecked" )
-				final SourceState< ? extends Volatile< ? > > vsource = ( SourceState< ? extends Volatile< ? > > ) source;
-				return createSingleSourceVolatileProjector( viewerState, vsource, sourceIndex, screenScaleIndex, screenImage, maskArray );
-			}
+			// restore interrupted state
+			Thread.currentThread().interrupt();
 		}
-
-		final AffineTransform3D screenScaleTransform = screenScaleTransforms[ currentScreenScaleIndex ];
-		final int bestLevel = viewerState.getBestMipMapLevel( screenScaleTransform, sourceIndex );
-		return new SimpleVolatileProjector<>(
-				getTransformedSource( viewerState, source.getSpimSource(), screenScaleTransform, bestLevel, null ),
-				source.getConverter(), screenImage, numRenderingThreads, renderingExecutorService );
 	}
 
-	private < T extends Volatile< ? > > VolatileProjector createSingleSourceVolatileProjector(
+	private VolatileProjector createProjector(
 			final ViewerState viewerState,
-			final SourceState< T > source,
-			final int sourceIndex,
+			final List< SourceAndConverter< ? > > visibleSourcesOnScreen,
 			final int screenScaleIndex,
-			final ARGBScreenImage screenImage,
-			final byte[] maskArray )
+			final RandomAccessibleInterval< ARGBType > screenImage,
+			final int offsetX,
+			final int offsetY )
 	{
-		final AffineTransform3D screenScaleTransform = screenScaleTransforms[ currentScreenScaleIndex ];
-		final ArrayList< RandomAccessible< T > > renderList = new ArrayList<>();
-		final Source< T > spimSource = source.getSpimSource();
-		final int t = viewerState.getCurrentTimepoint();
-
-		final MipmapOrdering ordering = MipmapOrdering.class.isInstance( spimSource ) ?
-			( MipmapOrdering ) spimSource : new DefaultMipmapOrdering( spimSource );
-
-		final AffineTransform3D screenTransform = new AffineTransform3D();
-		viewerState.getViewerTransform( screenTransform );
+		final AffineTransform3D screenScaleTransform = screenScales.get( screenScaleIndex ).scaleTransform();
+		final AffineTransform3D screenTransform = viewerState.getViewerTransform();
 		screenTransform.preConcatenate( screenScaleTransform );
-		final MipmapHints hints = ordering.getMipmapHints( screenTransform, t, previousTimepoint );
-		final List< Level > levels = hints.getLevels();
-
-		if ( prefetchCells )
-		{
-			Collections.sort( levels, MipmapOrdering.prefetchOrderComparator );
-			for ( final Level l : levels )
-			{
-				final CacheHints cacheHints = l.getPrefetchCacheHints();
-				if ( cacheHints == null || cacheHints.getLoadingStrategy() != LoadingStrategy.DONTLOAD )
-					prefetch( viewerState, spimSource, screenScaleTransform, l.getMipmapLevel(), cacheHints, screenImage );
-			}
-		}
-
-		Collections.sort( levels, MipmapOrdering.renderOrderComparator );
-		for ( final Level l : levels )
-			renderList.add( getTransformedSource( viewerState, spimSource, screenScaleTransform, l.getMipmapLevel(), l.getRenderCacheHints() ) );
-
-		if ( hints.renewHintsAfterPaintingOnce() )
-			newFrameRequest = true;
-
-		return new VolatileHierarchyProjector<>( renderList, source.getConverter(), screenImage, maskArray, numRenderingThreads, renderingExecutorService );
-	}
-
-	private static < T > RandomAccessible< T > getTransformedSource(
-			final ViewerState viewerState,
-			final Source< T > source,
-			final AffineTransform3D screenScaleTransform,
-			final int mipmapIndex,
-			final CacheHints cacheHints )
-	{
-		final int timepoint = viewerState.getCurrentTimepoint();
-
-		final RandomAccessibleInterval< T > img = source.getSource( timepoint, mipmapIndex );
-		if ( VolatileCachedCellImg.class.isInstance( img ) )
-			( ( VolatileCachedCellImg< ?, ? > ) img ).setCacheHints( cacheHints );
-
-		final Interpolation interpolation = viewerState.getInterpolation();
-		final RealRandomAccessible< T > ipimg = source.getInterpolatedSource( timepoint, mipmapIndex, interpolation );
-
-		final AffineTransform3D sourceToScreen = new AffineTransform3D();
-		viewerState.getViewerTransform( sourceToScreen );
-		final AffineTransform3D sourceTransform = new AffineTransform3D();
-		source.getSourceTransform( timepoint, mipmapIndex, sourceTransform );
-		sourceToScreen.concatenate( sourceTransform );
-		sourceToScreen.preConcatenate( screenScaleTransform );
-
-		return RealViews.affine( ipimg, sourceToScreen );
-	}
-
-	private static < T > void prefetch(
-			final ViewerState viewerState,
-			final Source< T > source,
-			final AffineTransform3D screenScaleTransform,
-			final int mipmapIndex,
-			final CacheHints prefetchCacheHints,
-			final Dimensions screenInterval )
-	{
-		final int timepoint = viewerState.getCurrentTimepoint();
-		final RandomAccessibleInterval< T > img = source.getSource( timepoint, mipmapIndex );
-		if ( VolatileCachedCellImg.class.isInstance( img ) )
-		{
-			final VolatileCachedCellImg< ?, ? > cellImg = ( VolatileCachedCellImg< ?, ? > ) img;
-
-			CacheHints hints = prefetchCacheHints;
-			if ( hints == null )
-			{
-				final CacheHints d = cellImg.getDefaultCacheHints();
-				hints = new CacheHints( LoadingStrategy.VOLATILE, d.getQueuePriority(), false );
-			}
-			cellImg.setCacheHints( hints );
-			final int[] cellDimensions = new int[ 3 ];
-			cellImg.getCellGrid().cellDimensions( cellDimensions );
-			final long[] dimensions = new long[ 3 ];
-			cellImg.dimensions( dimensions );
-			final RandomAccess< ? > cellsRandomAccess = cellImg.getCells().randomAccess();
-
-			final Interpolation interpolation = viewerState.getInterpolation();
-
-			final AffineTransform3D sourceToScreen = new AffineTransform3D();
-			viewerState.getViewerTransform( sourceToScreen );
-			final AffineTransform3D sourceTransform = new AffineTransform3D();
-			source.getSourceTransform( timepoint, mipmapIndex, sourceTransform );
-			sourceToScreen.concatenate( sourceTransform );
-			sourceToScreen.preConcatenate( screenScaleTransform );
-
-			Prefetcher.fetchCells( sourceToScreen, cellDimensions, dimensions, screenInterval, interpolation, cellsRandomAccess );
-		}
-	}
-
-	private static TransformAwareRenderTarget wrapTransformAwareRenderTarget( final RenderTarget t )
-	{
-		if ( t instanceof TransformAwareRenderTarget )
-			return ( TransformAwareRenderTarget ) t;
-		else
-			return new TransformAwareRenderTarget()
-			{
-				@Override
-				public BufferedImage setBufferedImage( final BufferedImage img )
-				{
-					return t.setBufferedImage( img );
-				}
-
-				@Override
-				public int getWidth()
-				{
-					return t.getWidth();
-				}
-
-				@Override
-				public int getHeight()
-				{
-					return t.getHeight();
-				}
-
-				@Override
-				public BufferedImage setBufferedImageAndTransform( final BufferedImage img, final AffineTransform3D transform )
-				{
-					return t.setBufferedImage( img );
-				}
-
-				@Override
-				public void removeTransformListener( final TransformListener< AffineTransform3D > listener )
-				{}
-
-				@Override
-				public void addTransformListener( final TransformListener< AffineTransform3D > listener, final int index )
-				{}
-
-				@Override
-				public void addTransformListener( final TransformListener< AffineTransform3D > listener )
-				{}
-			};
+		screenTransform.translate( -offsetX, -offsetY, 0 );
+
+		final VolatileProjector projector = projectorFactory.createProjector(
+				viewerState,
+				visibleSourcesOnScreen,
+				screenImage,
+				screenTransform,
+				renderStorage );
+		CacheIoTiming.getIoTimeBudget().reset( iobudget );
+		return projector;
 	}
 }
diff --git a/src/main/java/bdv/viewer/render/PainterThread.java b/src/main/java/bdv/viewer/render/PainterThread.java
new file mode 100644
index 0000000000000000000000000000000000000000..4c4083e3f3ce74fa4abe31564b2c357f2ca68349
--- /dev/null
+++ b/src/main/java/bdv/viewer/render/PainterThread.java
@@ -0,0 +1,118 @@
+/*
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.viewer.render;
+
+import bdv.viewer.RequestRepaint;
+import java.util.concurrent.RejectedExecutionException;
+
+/**
+ * Thread to repaint display.
+ */
+public class PainterThread extends Thread implements RequestRepaint
+{
+	public interface Paintable
+	{
+		/**
+		 * This is called by the painter thread to repaint the display.
+		 */
+		void paint();
+	}
+
+	private final Paintable paintable;
+
+	private boolean pleaseRepaint;
+
+	public PainterThread( final Paintable paintable )
+	{
+		this( null, "PainterThread", paintable );
+	}
+
+	public PainterThread( final ThreadGroup group, final Paintable paintable )
+	{
+		this( group, "PainterThread", paintable );
+	}
+
+	public PainterThread( final ThreadGroup group, final String name, final Paintable paintable )
+	{
+		super( group, name );
+		this.paintable = paintable;
+		this.pleaseRepaint = false;
+	}
+
+	@Override
+	public void run()
+	{
+		while ( !isInterrupted() )
+		{
+			final boolean b;
+			synchronized ( this )
+			{
+				b = pleaseRepaint;
+				pleaseRepaint = false;
+			}
+			if ( b )
+				try
+				{
+					paintable.paint();
+				}
+				catch ( final RejectedExecutionException e )
+				{
+					// this happens when the rendering threadpool
+					// is killed before the painter thread.
+				}
+			synchronized ( this )
+			{
+				try
+				{
+					if ( !pleaseRepaint )
+						wait();
+				}
+				catch ( final InterruptedException e )
+				{
+					break;
+				}
+			}
+		}
+	}
+
+	/**
+	 * Request repaint. This will trigger a call to {@link Paintable#paint()}
+	 * from the {@link PainterThread}.
+	 */
+	@Override
+	public void requestRepaint()
+	{
+		synchronized ( this )
+		{
+//			DebugHelpers.printStackTrace( 15 );
+			pleaseRepaint = true;
+			notify();
+		}
+	}
+}
diff --git a/src/main/java/bdv/viewer/render/Prefetcher.java b/src/main/java/bdv/viewer/render/Prefetcher.java
index 6153ca1a060855923275d5813ffc15bcbf0529c5..16a02f068c77fdcc9486276dfe9f049158df6974 100644
--- a/src/main/java/bdv/viewer/render/Prefetcher.java
+++ b/src/main/java/bdv/viewer/render/Prefetcher.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/bdv/viewer/render/ProjectorFactory.java b/src/main/java/bdv/viewer/render/ProjectorFactory.java
new file mode 100644
index 0000000000000000000000000000000000000000..c262a7c515a060a36375bfa28b75b70a29ee1e40
--- /dev/null
+++ b/src/main/java/bdv/viewer/render/ProjectorFactory.java
@@ -0,0 +1,336 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.viewer.render;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+
+import net.imglib2.Dimensions;
+import net.imglib2.RandomAccess;
+import net.imglib2.RandomAccessible;
+import net.imglib2.RandomAccessibleInterval;
+import net.imglib2.RealRandomAccessible;
+import net.imglib2.Volatile;
+import net.imglib2.cache.volatiles.CacheHints;
+import net.imglib2.cache.volatiles.LoadingStrategy;
+import net.imglib2.realtransform.AffineTransform3D;
+import net.imglib2.realtransform.RealViews;
+import net.imglib2.type.numeric.ARGBType;
+
+import bdv.img.cache.VolatileCachedCellImg;
+import bdv.util.MipmapTransforms;
+import bdv.viewer.Interpolation;
+import bdv.viewer.Source;
+import bdv.viewer.SourceAndConverter;
+import bdv.viewer.ViewerState;
+
+/**
+ * Creates projectors for rendering a given {@code ViewerState} to a given
+ * {@code screenImage}, with the current visible sources and timepoint of the
+ * {@code ViewerState}, and a given {@code screenTransform} from global
+ * coordinates to coordinates in the {@code screenImage}.
+ */
+class ProjectorFactory
+{
+	/**
+	 * How many threads to use for rendering.
+	 */
+	private final int numRenderingThreads;
+
+	/**
+	 * {@link ExecutorService} used for rendering.
+	 */
+	private final ExecutorService renderingExecutorService;
+
+	/**
+	 * Whether volatile versions of sources should be used if available.
+	 */
+	private final boolean useVolatileIfAvailable;
+
+	/**
+	 * Constructs projector that combines rendere ARGB images for individual
+	 * sources to the final screen image. This can be used to customize how
+	 * sources are combined.
+	 */
+	private final AccumulateProjectorFactory< ARGBType > accumulateProjectorFactory;
+
+	/**
+	 * Whether repainting should be triggered after the previously
+	 * {@link #createProjector constructed} projector returns an incomplete
+	 * result.
+	 * <p>
+	 * The use case for this is the following:
+	 * <p>
+	 * When scrolling through time, we often get frames for which no data was
+	 * loaded yet. To speed up rendering in these cases, use only two mipmap
+	 * levels: the optimal and the coarsest. By doing this, we require at most
+	 * two passes over the image at the expense of ignoring data present in
+	 * intermediate mipmap levels. The assumption is, that we will either be
+	 * moving back and forth between images that have all data present already
+	 * or that we move to a new image with no data present at all.
+	 * <p>
+	 * However, we only want this two-pass rendering to happen once, then switch
+	 * to normal multi-pass rendering if we remain longer on the same frame.
+	 * <p>
+	 * (This method is implemented by {@link DefaultMipmapOrdering}.)
+	 */
+	private boolean newFrameRequest;
+
+	/**
+	 * The timepoint for which last a projector was {@link #createProjector
+	 * created}.
+	 */
+	private int previousTimepoint = -1;
+
+	// TODO: should be settable
+	private final boolean prefetchCells = true;
+
+	/**
+	 * @param numRenderingThreads
+	 *     How many threads to use for rendering.
+	 * @param renderingExecutorService
+	 *     if non-null, this is used for rendering. Note, that it is still
+	 *     important to supply the numRenderingThreads parameter, because that
+	 *     is used to determine into how many sub-tasks rendering is split.
+	 * @param useVolatileIfAvailable
+	 *     whether volatile versions of sources should be used if available.
+	 * @param accumulateProjectorFactory
+	 *     can be used to customize how sources are combined.
+	 */
+	public ProjectorFactory(
+			final int numRenderingThreads,
+			final ExecutorService renderingExecutorService,
+			final boolean useVolatileIfAvailable,
+			final AccumulateProjectorFactory< ARGBType > accumulateProjectorFactory )
+	{
+		this.numRenderingThreads = numRenderingThreads;
+		this.renderingExecutorService = renderingExecutorService;
+		this.useVolatileIfAvailable = useVolatileIfAvailable;
+		this.accumulateProjectorFactory = accumulateProjectorFactory;
+	}
+
+	/**
+	 * Create a projector for rendering the specified {@code ViewerState} to the
+	 * specified {@code screenImage}, with the current visible sources (visible
+	 * in {@code ViewerState} and actually currently visible on screen) and
+	 * timepoint of the {@code ViewerState}, and the specified
+	 * {@code screenTransform} from global coordinates to coordinates in the
+	 * {@code screenImage}.
+	 */
+	public VolatileProjector createProjector(
+			final ViewerState viewerState,
+			final List< SourceAndConverter< ? > > visibleSourcesOnScreen,
+			final RandomAccessibleInterval< ARGBType > screenImage,
+			final AffineTransform3D screenTransform,
+			final RenderStorage renderStorage )
+	{
+		/*
+		 * This shouldn't be necessary, with
+		 * CacheHints.LoadingStrategy==VOLATILE
+		 */
+//		CacheIoTiming.getIoTimeBudget().clear(); // clear time budget such that prefetching doesn't wait for loading blocks.
+		newFrameRequest = false;
+
+		final int width = ( int ) screenImage.dimension( 0 );
+		final int height = ( int ) screenImage.dimension( 1 );
+
+		VolatileProjector projector;
+		if ( visibleSourcesOnScreen.isEmpty() )
+			projector = new EmptyProjector<>( screenImage );
+		else if ( visibleSourcesOnScreen.size() == 1 )
+		{
+			final byte[] maskArray = renderStorage.getMaskArray( 0 );
+			projector = createSingleSourceProjector( viewerState, visibleSourcesOnScreen.get( 0 ), screenImage, screenTransform, maskArray );
+		}
+		else
+		{
+			final ArrayList< VolatileProjector > sourceProjectors = new ArrayList<>();
+			final ArrayList< RandomAccessibleInterval< ARGBType > > sourceImages = new ArrayList<>();
+			int j = 0;
+			for ( final SourceAndConverter< ? > source : visibleSourcesOnScreen )
+			{
+				final RandomAccessibleInterval< ARGBType > renderImage = renderStorage.getRenderImage( width, height, j );
+				final byte[] maskArray = renderStorage.getMaskArray( j );
+				++j;
+				final VolatileProjector p = createSingleSourceProjector( viewerState, source, renderImage, screenTransform, maskArray );
+				sourceProjectors.add( p );
+				sourceImages.add( renderImage );
+			}
+			projector = accumulateProjectorFactory.createProjector( sourceProjectors, visibleSourcesOnScreen, sourceImages, screenImage, numRenderingThreads, renderingExecutorService );
+		}
+		previousTimepoint = viewerState.getCurrentTimepoint();
+		return projector;
+	}
+
+	private < T > VolatileProjector createSingleSourceProjector(
+			final ViewerState viewerState,
+			final SourceAndConverter< T > source,
+			final RandomAccessibleInterval< ARGBType > screenImage,
+			final AffineTransform3D screenTransform,
+			final byte[] maskArray )
+	{
+		if ( useVolatileIfAvailable )
+		{
+			if ( source.asVolatile() != null )
+				return createSingleSourceVolatileProjector( viewerState, source.asVolatile(), screenImage, screenTransform, maskArray );
+			else if ( source.getSpimSource().getType() instanceof Volatile )
+			{
+				@SuppressWarnings( "unchecked" )
+				final SourceAndConverter< ? extends Volatile< ? > > vsource = ( SourceAndConverter< ? extends Volatile< ? > > ) source;
+				return createSingleSourceVolatileProjector( viewerState, vsource, screenImage, screenTransform, maskArray );
+			}
+		}
+
+		final int bestLevel = getBestMipMapLevel( viewerState, source, screenTransform );
+		return new SimpleVolatileProjector<>(
+				getTransformedSource( viewerState, source.getSpimSource(), screenTransform, bestLevel, null ),
+				source.getConverter(), screenImage, numRenderingThreads, renderingExecutorService );
+	}
+
+	private < T extends Volatile< ? > > VolatileProjector createSingleSourceVolatileProjector(
+			final ViewerState viewerState,
+			final SourceAndConverter< T > source,
+			final RandomAccessibleInterval< ARGBType > screenImage,
+			final AffineTransform3D screenTransform,
+			final byte[] maskArray )
+	{
+		final ArrayList< RandomAccessible< T > > renderList = new ArrayList<>();
+		final Source< T > spimSource = source.getSpimSource();
+		final int t = viewerState.getCurrentTimepoint();
+
+		final MipmapOrdering ordering = spimSource instanceof MipmapOrdering ?
+				( MipmapOrdering ) spimSource : new DefaultMipmapOrdering( spimSource );
+
+		final MipmapOrdering.MipmapHints hints = ordering.getMipmapHints( screenTransform, t, previousTimepoint );
+		final List< MipmapOrdering.Level > levels = hints.getLevels();
+
+		if ( prefetchCells )
+		{
+			levels.sort( MipmapOrdering.prefetchOrderComparator );
+			for ( final MipmapOrdering.Level l : levels )
+			{
+				final CacheHints cacheHints = l.getPrefetchCacheHints();
+				if ( cacheHints == null || cacheHints.getLoadingStrategy() != LoadingStrategy.DONTLOAD )
+					prefetch( viewerState, spimSource, screenTransform, l.getMipmapLevel(), cacheHints, screenImage );
+			}
+		}
+
+		levels.sort( MipmapOrdering.renderOrderComparator );
+		for ( final MipmapOrdering.Level l : levels )
+			renderList.add( getTransformedSource( viewerState, spimSource, screenTransform, l.getMipmapLevel(), l.getRenderCacheHints() ) );
+
+		if ( hints.renewHintsAfterPaintingOnce() )
+			newFrameRequest = true;
+
+		return new VolatileHierarchyProjector<>( renderList, source.getConverter(), screenImage, maskArray, numRenderingThreads, renderingExecutorService );
+	}
+
+	/**
+	 * Get the mipmap level that best matches the given screen scale for the
+	 * given source.
+	 *
+	 * @param screenTransform
+	 *     transforms screen image coordinates to global coordinates.
+	 *
+	 * @return mipmap level
+	 */
+	private static int getBestMipMapLevel(
+			final ViewerState viewerState,
+			final SourceAndConverter< ? > source,
+			final AffineTransform3D screenTransform )
+	{
+		return MipmapTransforms.getBestMipMapLevel( screenTransform, source.getSpimSource(), viewerState.getCurrentTimepoint() );
+	}
+
+	private static < T > RandomAccessible< T > getTransformedSource(
+			final ViewerState viewerState,
+			final Source< T > source,
+			final AffineTransform3D screenTransform,
+			final int mipmapIndex,
+			final CacheHints cacheHints )
+	{
+		final int timepoint = viewerState.getCurrentTimepoint();
+
+		final RandomAccessibleInterval< T > img = source.getSource( timepoint, mipmapIndex );
+		if ( img instanceof VolatileCachedCellImg )
+			( ( VolatileCachedCellImg< ?, ? > ) img ).setCacheHints( cacheHints );
+
+		final Interpolation interpolation = viewerState.getInterpolation();
+		final RealRandomAccessible< T > ipimg = source.getInterpolatedSource( timepoint, mipmapIndex, interpolation );
+
+		final AffineTransform3D sourceToScreen = new AffineTransform3D();
+		source.getSourceTransform( timepoint, mipmapIndex, sourceToScreen );
+		sourceToScreen.preConcatenate( screenTransform );
+
+		return RealViews.affine( ipimg, sourceToScreen );
+	}
+
+	private static < T > void prefetch(
+			final ViewerState viewerState,
+			final Source< T > source,
+			final AffineTransform3D screenTransform,
+			final int mipmapIndex,
+			final CacheHints prefetchCacheHints,
+			final Dimensions screenInterval )
+	{
+		final int timepoint = viewerState.getCurrentTimepoint();
+		final RandomAccessibleInterval< T > img = source.getSource( timepoint, mipmapIndex );
+		if ( img instanceof VolatileCachedCellImg )
+		{
+			final VolatileCachedCellImg< ?, ? > cellImg = ( VolatileCachedCellImg< ?, ? > ) img;
+
+			CacheHints hints = prefetchCacheHints;
+			if ( hints == null )
+			{
+				final CacheHints d = cellImg.getDefaultCacheHints();
+				hints = new CacheHints( LoadingStrategy.VOLATILE, d.getQueuePriority(), false );
+			}
+			cellImg.setCacheHints( hints );
+			final int[] cellDimensions = new int[ 3 ];
+			cellImg.getCellGrid().cellDimensions( cellDimensions );
+			final long[] dimensions = new long[ 3 ];
+			cellImg.dimensions( dimensions );
+			final RandomAccess< ? > cellsRandomAccess = cellImg.getCells().randomAccess();
+
+			final Interpolation interpolation = viewerState.getInterpolation();
+
+			final AffineTransform3D sourceToScreen = new AffineTransform3D();
+			source.getSourceTransform( timepoint, mipmapIndex, sourceToScreen );
+			sourceToScreen.preConcatenate( screenTransform );
+
+			Prefetcher.fetchCells( sourceToScreen, cellDimensions, dimensions, screenInterval, interpolation, cellsRandomAccess );
+		}
+	}
+
+	public boolean requestNewFrameIfIncomplete()
+	{
+		return newFrameRequest;
+	}
+}
diff --git a/src/main/java/bdv/viewer/render/ProjectorUtils.java b/src/main/java/bdv/viewer/render/ProjectorUtils.java
new file mode 100644
index 0000000000000000000000000000000000000000..12a96dc9728ebe620f1a7feedfad214ed5f0734d
--- /dev/null
+++ b/src/main/java/bdv/viewer/render/ProjectorUtils.java
@@ -0,0 +1,58 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.viewer.render;
+
+import net.imglib2.RandomAccessible;
+import net.imglib2.img.array.ArrayImg;
+import net.imglib2.img.basictypeaccess.array.IntArray;
+import net.imglib2.type.numeric.ARGBType;
+
+public class ProjectorUtils
+{
+	/**
+	 * Extracts the underlying {@code int[]} array in case {@code img} is a
+	 * standard {@code ArrayImg<ARGBType>}. This supports certain (optional)
+	 * optimizations in projector implementations.
+	 *
+	 * @return the underlying {@code int[]} array of {@code img}, if it is a
+	 * standard {@code ArrayImg<ARGBType>}. Otherwise {@code null}.
+	 */
+	public static int[] getARGBArrayImgData( final RandomAccessible< ? > img )
+	{
+		if ( ! ( img instanceof ArrayImg ) )
+			return null;
+		final ArrayImg< ?, ? > aimg = ( ArrayImg< ?, ? > ) img;
+		if( ! ( aimg.firstElement() instanceof ARGBType ) )
+			return null;
+		final Object access = aimg.update( null );
+		if ( ! ( access instanceof IntArray ) )
+			return null;
+		return ( ( IntArray ) access ).getCurrentStorageArray();
+	}
+}
diff --git a/src/main/java/bdv/viewer/render/RenderResult.java b/src/main/java/bdv/viewer/render/RenderResult.java
new file mode 100644
index 0000000000000000000000000000000000000000..6957817fb65bd4bdb45c98e8e192fd2db9959f5c
--- /dev/null
+++ b/src/main/java/bdv/viewer/render/RenderResult.java
@@ -0,0 +1,98 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.viewer.render;
+
+import net.imglib2.Interval;
+import net.imglib2.RandomAccessibleInterval;
+import net.imglib2.realtransform.AffineTransform3D;
+import net.imglib2.type.numeric.ARGBType;
+
+/**
+ * Provides the {@link MultiResolutionRenderer renderer} with a target image
+ * ({@code RandomAccessibleInterval<ARGBType>}) to render to. Provides the
+ * {@link RenderTarget} with the rendered image and transform etc necessary to
+ * display it.
+ */
+public interface RenderResult
+{
+	/**
+	 * Allocate storage such that {@link #getTargetImage()} holds an image of
+	 * {@code width * height}.
+	 * <p>
+	 * (Called by the {@link MultiResolutionRenderer renderer}.)
+	 */
+	void init( int width, int height );
+
+	/**
+	 * Get the image to render to.
+	 * <p>
+	 * (Called by the {@link MultiResolutionRenderer renderer}.)
+	 *
+	 * @return the image to render to
+	 */
+	// TODO: rename getTargetImage() ???
+	RandomAccessibleInterval< ARGBType > getTargetImage();
+
+	/**
+	 * Get the viewer transform used to render image. This is with respect to
+	 * the screen resolution (doesn't include scaling).
+	 * <p>
+	 * (Called by the {@link MultiResolutionRenderer renderer} to set the
+	 * transform.)
+	 */
+	AffineTransform3D getViewerTransform();
+
+	/**
+	 * Get the scale factor from target coordinates to screen resolution.
+	 */
+	double getScaleFactor();
+
+	/**
+	 * Set the scale factor from target coordinates to screen resolution.
+	 */
+	void setScaleFactor( double scaleFactor );
+
+	/**
+	 * Fill in {@code interval} with data from {@code patch}, scaled by the
+	 * relative scale between this {@code RenderResult} and {@code patch}, and
+	 * shifted such that {@code (0,0)} of the {@code patch} is placed at
+	 * {@code (ox,oy)} of this {@code RenderResult}
+	 * <p>
+	 * Note that only data in {@code interval} will be modified, although the
+	 * scaled and shifted {@code patch} might fall partially outside.
+	 */
+	void patch( final RenderResult patch, final Interval interval, final double ox, final double oy );
+
+	/**
+	 * Notify that the {@link #getTargetImage() target image} data was changed.
+	 * <p>
+	 * (Called by the {@link MultiResolutionRenderer renderer}.)
+	 */
+	void setUpdated();
+}
diff --git a/src/main/java/bdv/viewer/render/RenderStorage.java b/src/main/java/bdv/viewer/render/RenderStorage.java
new file mode 100644
index 0000000000000000000000000000000000000000..edccd32fe4785d1030ce883b5ece314e876b1222
--- /dev/null
+++ b/src/main/java/bdv/viewer/render/RenderStorage.java
@@ -0,0 +1,92 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.viewer.render;
+
+import java.util.ArrayList;
+import java.util.List;
+import net.imglib2.RandomAccessibleInterval;
+import net.imglib2.img.array.ArrayImgs;
+import net.imglib2.type.numeric.ARGBType;
+
+/**
+ * Maintains {@code byte[]} and {@code int[]} arrays for mask and intermediate images needed for rendering.
+ * <p>
+ * Call {@link #checkRenewData} to update number and size of arrays when number of visible sources or screen size changes.
+ */
+class RenderStorage
+{
+	/**
+	 * Storage for mask images of {@link VolatileHierarchyProjector}. One array
+	 * per visible source.
+	 */
+	private final List< byte[] > renderMaskArrays = new ArrayList<>();
+
+	/**
+	 * Storage for render images of {@link VolatileHierarchyProjector}.
+	 * Used to render an individual source before combining to final target image.
+	 * One array per visible source, if more than one source is visible.
+	 * (If exactly one source is visible, it is rendered directly to the target image.)
+	 */
+	private final List< int[] > renderImageArrays = new ArrayList<>();
+
+	public void checkRenewData( final int screenW, final int screenH, final int numVisibleSources )
+	{
+		final int size = screenW * screenH;
+		final int currentSize = renderMaskArrays.isEmpty() ? 0 : renderMaskArrays.get( 0 ).length;
+		if  ( size != currentSize )
+			clear();
+
+		while ( renderMaskArrays.size() > numVisibleSources )
+			renderMaskArrays.remove( renderMaskArrays.size() - 1 );
+		while ( renderMaskArrays.size() < numVisibleSources )
+			renderMaskArrays.add( new byte[ size ] );
+
+		final int numRenderImages = numVisibleSources > 1 ? numVisibleSources : 0;
+		while ( renderImageArrays.size() > numRenderImages )
+			renderImageArrays.remove( renderImageArrays.size() - 1 );
+		while ( renderImageArrays.size() < numRenderImages )
+			renderImageArrays.add( new int[ size ] );
+	}
+
+	public byte[] getMaskArray( final int index )
+	{
+		return renderMaskArrays.get( index );
+	}
+
+	public RandomAccessibleInterval< ARGBType > getRenderImage( final int width, final int height, final int index )
+	{
+		return ArrayImgs.argbs( renderImageArrays.get( index ), width, height );
+	}
+
+	public void clear()
+	{
+		renderMaskArrays.clear();
+		renderImageArrays.clear();
+	}
+}
diff --git a/src/main/java/bdv/viewer/render/RenderTarget.java b/src/main/java/bdv/viewer/render/RenderTarget.java
new file mode 100644
index 0000000000000000000000000000000000000000..4151fbc6168c2a73382718e4992fd8c974cae917
--- /dev/null
+++ b/src/main/java/bdv/viewer/render/RenderTarget.java
@@ -0,0 +1,78 @@
+/*
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.viewer.render;
+
+import bdv.viewer.render.RenderResult;
+
+/**
+ * Receiver for a rendered image (to be drawn onto a canvas later).
+ * <p>
+ * A renderer will render source data into a {@link RenderResult} and
+ * provide this to the {@code RenderTarget}.
+ * <p>
+ * See {@code BufferedImageOverlayRenderer}, which is both a {@code RenderTarget} and
+ * an {@code OverlayRenderer} that draws the {@code RenderResult}.
+ *
+ * @author Tobias Pietzsch
+ */
+public interface RenderTarget< R extends RenderResult >
+{
+	/**
+	 * Returns a {@code RenderResult} for rendering to.
+	 * This may be a new {@code RenderResult} or a previously {@link #setRenderResult set RenderResult}
+	 * that is no longer needed for display.
+	 * Note that consecutive {@code getReusableRenderResult()} calls without intermediate
+	 * {@code setRenderResult()} may return the same {@code RenderResult}.
+	 */
+	R getReusableRenderResult();
+
+	/**
+	 * Returns a new {@code RenderResult}.
+	 */
+	R createRenderResult();
+
+	/**
+	 * Set the {@link RenderResult} that is to be drawn on the canvas.
+	 */
+	void setRenderResult( R renderResult );
+
+	/**
+	 * Get the current canvas width.
+	 *
+	 * @return canvas width.
+	 */
+	int getWidth();
+
+	/**
+	 * Get the current canvas height.
+	 *
+	 * @return canvas height.
+	 */
+	int getHeight();
+}
diff --git a/src/main/java/bdv/viewer/render/ScreenScales.java b/src/main/java/bdv/viewer/render/ScreenScales.java
new file mode 100644
index 0000000000000000000000000000000000000000..eb19226070a885aea25b33fb79215da1e64ef27b
--- /dev/null
+++ b/src/main/java/bdv/viewer/render/ScreenScales.java
@@ -0,0 +1,365 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.viewer.render;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import net.imglib2.Interval;
+import net.imglib2.realtransform.AffineTransform3D;
+import net.imglib2.util.Intervals;
+
+/**
+ * Maintains current sizes and transforms at every screen scale level. Records
+ * interval rendering requests. Suggests full frame or interval scale to render
+ * in order to meet a specified target rendering time in nanoseconds.
+ */
+class ScreenScales
+{
+	/**
+	 * Target rendering time in nanoseconds. The rendering time for the coarsest
+	 * rendered scale should be below this threshold. After the coarsest scale,
+	 * increasingly finer scales are rendered, but these render passes may be
+	 * canceled (while the coarsest may not).
+	 */
+	private final double targetRenderNanos;
+
+	private final List< ScreenScale > screenScales;
+
+	private int screenW = 0;
+
+	private int screenH = 0;
+
+	/**
+	 * @param screenScaleFactors
+	 *     Scale factors from the viewer canvas to screen images of different
+	 *     resolutions. A scale factor of 1 means 1 pixel in the screen image is
+	 *     displayed as 1 pixel on the canvas, a scale factor of 0.5 means 1
+	 *     pixel in the screen image is displayed as 2 pixel on the canvas, etc.
+	 * @param targetRenderNanos
+	 *     Target rendering time in nanoseconds. The rendering time for the
+	 *     coarsest rendered scale should be below this threshold.
+	 */
+	public ScreenScales( final double[] screenScaleFactors, final double targetRenderNanos )
+	{
+		this.targetRenderNanos = targetRenderNanos;
+		screenScales = new ArrayList<>();
+		for ( final double scale : screenScaleFactors )
+			screenScales.add( new ScreenScale( scale ) );
+	}
+
+	/**
+	 * Check whether the screen size was changed and resize {@link #screenScales} accordingly.
+	 *
+	 * @return whether the size was changed.
+	 */
+	public boolean checkResize( final int newScreenW, final int newScreenH )
+	{
+		if ( newScreenW != screenW || newScreenH != screenH )
+		{
+			screenW = newScreenW;
+			screenH = newScreenH;
+			screenScales.forEach( s -> s.resize( screenW, screenH ) );
+			return true;
+		}
+		return false;
+	}
+
+	/**
+	 * @return the screen scale at {@code index}
+	 */
+	public ScreenScale get( final int index )
+	{
+		return screenScales.get( index );
+	}
+
+	/**
+	 * @return number of screen scales.
+	 */
+	public int size()
+	{
+		return screenScales.size();
+	}
+
+	public int suggestScreenScale( final double renderNanosPerPixel )
+	{
+		for ( int i = 0; i < screenScales.size() - 1; i++ )
+		{
+			final double renderTime = screenScales.get( i ).estimateRenderNanos( renderNanosPerPixel );
+			if ( renderTime <= targetRenderNanos )
+				return i;
+		}
+		return screenScales.size() - 1;
+	}
+
+	public int suggestIntervalScreenScale( final double renderNanosPerPixel, final int minScreenScaleIndex )
+	{
+		for ( int i = minScreenScaleIndex; i < screenScales.size() - 1; i++ )
+		{
+			final double renderTime = screenScales.get( i ).estimateIntervalRenderNanos( renderNanosPerPixel );
+			if ( renderTime <= targetRenderNanos )
+				return i;
+		}
+		return screenScales.size() - 1;
+	}
+
+	/**
+	 * Compute the intersection of {@code interval} and the screen area.
+	 *
+	 * @param interval
+	 * 		a 2D interval in screen coordinates
+	 *
+	 * @return intersection of {@code interval} and the screen area
+	 */
+	public Interval clipToScreen( final Interval interval )
+	{
+		// This is equivalent to
+		// Intervals.intersect( interval, new FinalInterval( screenW, screenH ) );
+		return Intervals.createMinMax(
+				Math.max( 0, interval.min( 0 ) ),
+				Math.max( 0, interval.min( 1 ) ),
+				Math.min( screenW - 1, interval.max( 0 ) ),
+				Math.min( screenH - 1, interval.max( 1 ) ) );
+	}
+
+	public void requestInterval( final Interval screenInterval )
+	{
+		screenScales.forEach( s -> s.requestInterval( screenInterval ) );
+	}
+
+	public void clearRequestedIntervals()
+	{
+		screenScales.forEach( ScreenScale::pullScreenInterval );
+	}
+
+	public IntervalRenderData pullIntervalRenderData( final int intervalScaleIndex, final int targetScaleIndex )
+	{
+		return new IntervalRenderData( intervalScaleIndex, targetScaleIndex );
+	}
+
+	static class ScreenScale
+	{
+		/**
+		 * Scale factor from the viewer coordinates to target image of this screen scale.
+		 */
+		private final double scale;
+
+		/**
+		 * The width of the target image at this ScreenScale.
+		 */
+		private int w = 0;
+
+		/**
+		 * The height of the target image at this ScreenScale.
+		 */
+		private int h = 0;
+
+		/**
+		 * The transformation from viewer to target image coordinates at this ScreenScale.
+		 */
+		private final AffineTransform3D scaleTransform = new AffineTransform3D();
+
+		/**
+		 * Pending interval request.
+		 * This is in viewer coordinates.
+		 * To transform to target coordinates of this scale, use {@link #scaleScreenInterval}.
+		 */
+		private Interval requestedScreenInterval = null;
+
+		/**
+		 * @param scale
+		 * 		Scale factor from the viewer coordinates to target image of this screen scale/
+		 */
+		ScreenScale( final double scale )
+		{
+			this.scale = scale;
+		}
+
+		/**
+		 * Add {@code screenInterval} to requested interval (union).
+		 * Note that the requested interval is maintained in screen coordinates!
+		 */
+		public void requestInterval( final Interval screenInterval )
+		{
+			requestedScreenInterval = requestedScreenInterval == null
+					? screenInterval
+					: Intervals.union( requestedScreenInterval, screenInterval );
+		}
+
+		/**
+		 * Return and clear requested interval.
+		 * Note that the requested interval is maintained in screen coordinates!
+		 */
+		public Interval pullScreenInterval()
+		{
+			final Interval interval = requestedScreenInterval;
+			requestedScreenInterval = null;
+			return interval;
+		}
+
+		void resize( final int screenW, final int screenH )
+		{
+			w = ( int ) Math.ceil( scale * screenW );
+			h = ( int ) Math.ceil( scale * screenH );
+
+			scaleTransform.set( scale, 0, 0 );
+			scaleTransform.set( scale, 1, 1 );
+			scaleTransform.set( 0.5 * scale - 0.5, 0, 3 );
+			scaleTransform.set( 0.5 * scale - 0.5, 1, 3 );
+
+			requestedScreenInterval = null;
+		}
+
+		double estimateRenderNanos( final double renderNanosPerPixel )
+		{
+			return renderNanosPerPixel * w * h;
+		}
+
+		double estimateIntervalRenderNanos( final double renderNanosPerPixel )
+		{
+			return renderNanosPerPixel * Intervals.numElements( scaleScreenInterval( requestedScreenInterval ) );
+		}
+
+		Interval scaleScreenInterval( final Interval requestedScreenInterval )
+		{
+			// This is equivalent to
+			// Intervals.intersect( new FinalInterval( w, h ), Intervals.smallestContainingInterval( Intervals.scale( requestedScreenInterval, screenToViewerScale ) ) );
+			return Intervals.createMinMax(
+					Math.max( 0, ( int ) Math.floor( requestedScreenInterval.min( 0 ) * scale ) ),
+					Math.max( 0, ( int ) Math.floor( requestedScreenInterval.min( 1 ) * scale ) ),
+					Math.min( w - 1, ( int ) Math.ceil( requestedScreenInterval.max( 0 ) * scale ) ),
+					Math.min( h - 1, ( int ) Math.ceil( requestedScreenInterval.max( 1 ) * scale ) )
+			);
+		}
+
+		public int width()
+		{
+			return w;
+		}
+
+		public int height()
+		{
+			return h;
+		}
+
+		public double scale()
+		{
+			return scale;
+		}
+
+		public AffineTransform3D scaleTransform()
+		{
+			return scaleTransform;
+		}
+	}
+
+	class IntervalRenderData
+	{
+		private final int renderScaleIndex;
+
+		private final Interval renderInterval;
+
+		private final Interval targetInterval;
+
+		private final double tx;
+
+		private final double ty;
+
+		private final Interval[] screenIntervals;
+
+		public IntervalRenderData( final int renderScaleIndex, final int targetScaleIndex )
+		{
+			this.renderScaleIndex = renderScaleIndex;
+
+			screenIntervals = new Interval[ size() ];
+			for ( int i = renderScaleIndex; i < screenIntervals.length; ++i )
+				screenIntervals[ i ] = get( i ).pullScreenInterval();
+			final Interval screenInterval = screenIntervals[ renderScaleIndex ];
+
+			final ScreenScale renderScale = get( renderScaleIndex );
+			renderInterval = renderScale.scaleScreenInterval( screenInterval );
+
+			final ScreenScale targetScale = get( targetScaleIndex );
+			targetInterval = targetScale.scaleScreenInterval( screenInterval );
+
+			final double relativeScale = targetScale.scale() / renderScale.scale();
+			tx = renderInterval.min( 0 ) * relativeScale;
+			ty = renderInterval.min( 1 ) * relativeScale;
+		}
+
+		public void reRequest()
+		{
+			for ( int i = renderScaleIndex; i < screenIntervals.length; ++i )
+			{
+				final Interval interval = screenIntervals[ i ];
+				if ( interval != null )
+					get( i ).requestInterval( interval );
+			}
+		}
+
+		public int width()
+		{
+			return ( int ) renderInterval.dimension( 0 );
+		}
+
+		public int height()
+		{
+			return ( int ) renderInterval.dimension( 1 );
+		}
+
+		public int offsetX()
+		{
+			return ( int ) renderInterval.min( 0 );
+		}
+		public int offsetY()
+		{
+			return ( int ) renderInterval.min( 1 );
+		}
+
+		public double scale()
+		{
+			return get( renderScaleIndex ).scale();
+		}
+
+		public Interval targetInterval()
+		{
+			return targetInterval;
+		}
+
+		public double tx()
+		{
+			return tx;
+		}
+
+		public double ty()
+		{
+			return ty;
+		}
+	}
+}
diff --git a/src/main/java/bdv/viewer/render/SimpleVolatileProjector.java b/src/main/java/bdv/viewer/render/SimpleVolatileProjector.java
new file mode 100644
index 0000000000000000000000000000000000000000..70eaef32c0b51bf9e9f66db840af685c39726a26
--- /dev/null
+++ b/src/main/java/bdv/viewer/render/SimpleVolatileProjector.java
@@ -0,0 +1,252 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.viewer.render;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicBoolean;
+import net.imglib2.FinalInterval;
+import net.imglib2.RandomAccess;
+import net.imglib2.RandomAccessible;
+import net.imglib2.RandomAccessibleInterval;
+import net.imglib2.converter.Converter;
+import net.imglib2.util.Intervals;
+import net.imglib2.util.StopWatch;
+
+// TODO Fix naming. This is a VolatileProjector for a non-volatile source...
+/**
+ * An {@link VolatileProjector}, that renders a target 2D
+ * {@code RandomAccessibleInterval} by copying values from a source
+ * {@code RandomAccessible}. The source can have more dimensions than the
+ * target. Target coordinate <em>(x,y)</em> is copied from source coordinate
+ * <em>(x,y,0,...,0)</em>.
+ * <p>
+ * A specified number of threads is used for rendering.
+ *
+ * @param <A>
+ *            pixel type of the source {@code RandomAccessible}.
+ * @param <B>
+ *            pixel type of the target {@code RandomAccessibleInterval}.
+ *
+ * @author Tobias Pietzsch
+ * @author Stephan Saalfeld
+ */
+public class SimpleVolatileProjector< A, B > implements VolatileProjector
+{
+	/**
+	 * A converter from the source pixel type to the target pixel type.
+	 */
+	private final Converter< ? super A, B > converter;
+
+	/**
+	 * The target interval. Pixels of the target interval should be set by
+	 * {@link #map}
+	 */
+	private final RandomAccessibleInterval< B > target;
+
+	private final RandomAccessible< A > source;
+
+	/**
+	 * Source interval which will be used for rendering. This is the 2D target
+	 * interval expanded to source dimensionality (usually 3D) with
+	 * {@code min=max=0} in the additional dimensions.
+	 */
+	private final FinalInterval sourceInterval;
+
+	/**
+	 * Number of threads to use for rendering
+	 */
+	private final int numThreads;
+
+	private final ExecutorService executorService;
+
+	/**
+	 * Time needed for rendering the last frame, in nano-seconds.
+	 */
+	private long lastFrameRenderNanoTime;
+
+	private AtomicBoolean canceled = new AtomicBoolean();
+
+	private boolean valid = false;
+
+	/**
+	 * Create new projector with the given source and a converter from source to
+	 * target pixel type.
+	 *
+	 * @param source
+	 *            source pixels.
+	 * @param converter
+	 *            converts from the source pixel type to the target pixel type.
+	 * @param target
+	 *            the target interval that this projector maps to
+	 * @param numThreads
+	 *            how many threads to use for rendering.
+	 * @param executorService
+	 */
+	public SimpleVolatileProjector(
+			final RandomAccessible< A > source,
+			final Converter< ? super A, B > converter,
+			final RandomAccessibleInterval< B > target,
+			final int numThreads,
+			final ExecutorService executorService )
+	{
+		this.converter = converter;
+		this.target = target;
+		this.source = source;
+
+		final int n = Math.max( 2, source.numDimensions() );
+		final long[] min = new long[ n ];
+		final long[] max = new long[ n ];
+		min[ 0 ] = target.min( 0 );
+		max[ 0 ] = target.max( 0 );
+		min[ 1 ] = target.min( 1 );
+		max[ 1 ] = target.max( 1 );
+		sourceInterval = new FinalInterval( min, max );
+
+		this.numThreads = numThreads;
+		this.executorService = executorService;
+		lastFrameRenderNanoTime = -1;
+	}
+
+	@Override
+	public void cancel()
+	{
+		canceled.set( true );
+	}
+
+	@Override
+	public long getLastFrameRenderNanoTime()
+	{
+		return lastFrameRenderNanoTime;
+	}
+
+	@Override
+	public boolean isValid()
+	{
+		return valid;
+	}
+
+	/**
+	 * Render the 2D target image by copying values from the source. Source can
+	 * have more dimensions than the target. Target coordinate <em>(x,y)</em> is
+	 * copied from source coordinate <em>(x,y,0,...,0)</em>.
+	 *
+	 * @return true if rendering was completed (all target pixels written).
+	 *         false if rendering was interrupted.
+	 */
+	@Override
+	public boolean map( final boolean clearUntouchedTargetPixels )
+	{
+		if ( canceled.get() )
+			return false;
+
+		final StopWatch stopWatch = StopWatch.createAndStart();
+
+		final int targetHeight = ( int ) target.dimension( 1 );
+		final int numTasks = numThreads <= 1 ? 1 : Math.min( numThreads * 10, targetHeight );
+		final double taskHeight = ( double ) targetHeight / numTasks;
+		final int[] taskStartHeights = new int[ numTasks + 1 ];
+		for ( int i = 0; i < numTasks; ++i )
+			taskStartHeights[ i ] = ( int ) ( i * taskHeight );
+		taskStartHeights[ numTasks ] = targetHeight;
+
+		final boolean createExecutor = ( executorService == null );
+		final ExecutorService ex = createExecutor ? Executors.newFixedThreadPool( numThreads ) : executorService;
+
+		final List< Callable< Void > > tasks = new ArrayList<>( numTasks );
+		for( int i = 0; i < numTasks; ++i )
+			tasks.add( createMapTask( taskStartHeights[ i ], taskStartHeights[ i + 1 ] ) );
+		try
+		{
+			ex.invokeAll( tasks );
+		}
+		catch ( final InterruptedException e )
+		{
+			Thread.currentThread().interrupt();
+		}
+
+		if ( createExecutor )
+			ex.shutdown();
+
+		lastFrameRenderNanoTime = stopWatch.nanoTime();
+
+		final boolean success = !canceled.get();
+		valid |= success;
+		return success;
+	}
+
+	/**
+	 * @return a {@code Callable} that runs {@code map(startHeight, endHeight)}
+	 */
+	private Callable< Void > createMapTask( final int startHeight, final int endHeight )
+	{
+		return Executors.callable( () -> map( startHeight, endHeight ), null );
+	}
+
+	/**
+	 * Copy lines from {@code y = startHeight} up to {@code endHeight}
+	 * (exclusive) from source to target. Check after
+	 * each line whether rendering was {@link #cancel() canceled}.
+	 *
+	 * @param startHeight
+	 *     start of line range to copy (relative to target min coordinate)
+	 * @param endHeight
+	 *     end (exclusive) of line range to copy (relative to target min
+	 *     coordinate)
+	 */
+	private void map( final int startHeight, final int endHeight )
+	{
+		if ( canceled.get() )
+			return;
+
+		final RandomAccess< B > targetRandomAccess = target.randomAccess( target );
+		final RandomAccess< A > sourceRandomAccess = source.randomAccess( sourceInterval );
+		final int width = ( int ) target.dimension( 0 );
+		final long[] smin = Intervals.minAsLongArray( sourceInterval );
+
+		final int targetMin = ( int ) target.min( 1 );
+		for ( int y = startHeight; y < endHeight; ++y )
+		{
+			if ( canceled.get() )
+				return;
+			smin[ 1 ] = y + targetMin;
+			sourceRandomAccess.setPosition( smin );
+			targetRandomAccess.setPosition( smin );
+			for ( int x = 0; x < width; ++x )
+			{
+				converter.convert( sourceRandomAccess.get(), targetRandomAccess.get() );
+				sourceRandomAccess.fwd( 0 );
+				targetRandomAccess.fwd( 0 );
+			}
+		}
+	}
+}
diff --git a/src/main/java/bdv/viewer/render/TransformAwareBufferedImageOverlayRenderer.java b/src/main/java/bdv/viewer/render/TransformAwareBufferedImageOverlayRenderer.java
deleted file mode 100644
index 4a30b5bb9cb1aec3e68a311ee82cd745f9014696..0000000000000000000000000000000000000000
--- a/src/main/java/bdv/viewer/render/TransformAwareBufferedImageOverlayRenderer.java
+++ /dev/null
@@ -1,171 +0,0 @@
-/*
- * #%L
- * BigDataViewer core classes with minimal dependencies
- * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
- * %%
- * 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 bdv.viewer.render;
-
-import java.awt.Graphics;
-import java.awt.Graphics2D;
-import java.awt.RenderingHints;
-import java.awt.image.BufferedImage;
-import java.util.concurrent.CopyOnWriteArrayList;
-
-import net.imglib2.realtransform.AffineTransform3D;
-import net.imglib2.ui.OverlayRenderer;
-import net.imglib2.ui.TransformListener;
-import net.imglib2.ui.overlay.BufferedImageOverlayRenderer;
-
-public class TransformAwareBufferedImageOverlayRenderer extends BufferedImageOverlayRenderer implements TransformAwareRenderTarget
-{
-	protected AffineTransform3D pendingTransform;
-
-	protected AffineTransform3D paintedTransform;
-
-	/**
-	 * These listeners will be notified about the transform that is associated
-	 * to the currently rendered image. This is intended for example for
-	 * {@link OverlayRenderer}s that need to exactly match the transform of
-	 * their overlaid content to the transform of the image.
-	 */
-	protected final CopyOnWriteArrayList< TransformListener< AffineTransform3D > > paintedTransformListeners;
-
-	public TransformAwareBufferedImageOverlayRenderer()
-	{
-		super();
-		pendingTransform = new AffineTransform3D();
-		paintedTransform = new AffineTransform3D();
-		paintedTransformListeners = new CopyOnWriteArrayList<>();
-	}
-
-	@Override
-	public synchronized BufferedImage setBufferedImageAndTransform( final BufferedImage img, final AffineTransform3D transform )
-	{
-		pendingTransform.set( transform );
-		return super.setBufferedImage( img );
-	}
-
-	@Override
-	public void drawOverlays( final Graphics g )
-	{
-		boolean notifyTransformListeners = false;
-		synchronized ( this )
-		{
-			if ( pending )
-			{
-				final BufferedImage tmp = bufferedImage;
-				bufferedImage = pendingImage;
-				paintedTransform.set( pendingTransform );
-				pendingImage = tmp;
-				pending = false;
-				notifyTransformListeners = true;
-			}
-		}
-		if ( bufferedImage != null )
-		{
-//			final StopWatch watch = new StopWatch();
-//			watch.start();
-//			( ( Graphics2D ) g ).setRenderingHint( RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR );
-			( ( Graphics2D ) g ).setRenderingHint( RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR );
-			( ( Graphics2D ) g ).setRenderingHint( RenderingHints.KEY_ALPHA_INTERPOLATION, RenderingHints.VALUE_ALPHA_INTERPOLATION_SPEED );
-			( ( Graphics2D ) g ).setRenderingHint( RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF );
-			( ( Graphics2D ) g ).setRenderingHint( RenderingHints.KEY_COLOR_RENDERING, RenderingHints.VALUE_COLOR_RENDER_SPEED );
-			( ( Graphics2D ) g ).setRenderingHint( RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_SPEED );
-			g.drawImage( bufferedImage, 0, 0, getWidth(), getHeight(), null );
-			if ( notifyTransformListeners )
-				for ( final TransformListener< AffineTransform3D > listener : paintedTransformListeners )
-					listener.transformChanged( paintedTransform );
-//			System.out.println( String.format( "g.drawImage() :%4d ms", watch.nanoTime() / 1000000 ) );
-		}
-	}
-
-	/**
-	 * Add a {@link TransformListener} to notify about viewer transformation
-	 * changes. Listeners will be notified when a new image has been rendered
-	 * (immediately before that image is displayed) with the viewer transform
-	 * used to render that image.
-	 *
-	 * @param listener
-	 *            the transform listener to add.
-	 */
-	@Override
-	public void addTransformListener( final TransformListener< AffineTransform3D > listener )
-	{
-		addTransformListener( listener, Integer.MAX_VALUE );
-	}
-
-	/**
-	 * Add a {@link TransformListener} to notify about viewer transformation
-	 * changes. Listeners will be notified when a new image has been rendered
-	 * (immediately before that image is displayed) with the viewer transform
-	 * used to render that image.
-	 *
-	 * @param listener
-	 *            the transform listener to add.
-	 * @param index
-	 *            position in the list of listeners at which to insert this one.
-	 */
-	@Override
-	public void addTransformListener( final TransformListener< AffineTransform3D > listener, final int index )
-	{
-		synchronized ( paintedTransformListeners )
-		{
-			final int s = paintedTransformListeners.size();
-			paintedTransformListeners.add( index < 0 ? 0 : index > s ? s : index, listener );
-			listener.transformChanged( paintedTransform );
-		}
-	}
-
-	/**
-	 * Remove a {@link TransformListener}.
-	 *
-	 * @param listener
-	 *            the transform listener to remove.
-	 */
-	@Override
-	public void removeTransformListener( final TransformListener< AffineTransform3D > listener )
-	{
-		synchronized ( paintedTransformListeners )
-		{
-			paintedTransformListeners.remove( listener );
-		}
-	}
-
-	/**
-	 * DON'T USE THIS.
-	 * <p>
-	 * This is a work around for JDK bug
-	 * https://bugs.openjdk.java.net/browse/JDK-8029147 which leads to
-	 * ViewerPanel not being garbage-collected when ViewerFrame is closed. So
-	 * instead we need to manually let go of resources...
-	 */
-	void kill()
-	{
-		bufferedImage = null;
-		pendingImage = null;
-	}
-}
diff --git a/src/main/java/bdv/viewer/render/VisibilityUtils.java b/src/main/java/bdv/viewer/render/VisibilityUtils.java
new file mode 100644
index 0000000000000000000000000000000000000000..3568be69b86bf756eb55950a7c5e73f17eebc503
--- /dev/null
+++ b/src/main/java/bdv/viewer/render/VisibilityUtils.java
@@ -0,0 +1,124 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.viewer.render;
+
+import bdv.util.MipmapTransforms;
+import bdv.viewer.Interpolation;
+import bdv.viewer.Source;
+import bdv.viewer.SourceAndConverter;
+import bdv.viewer.ViewerState;
+import java.util.List;
+import java.util.Set;
+import net.imglib2.FinalRealInterval;
+import net.imglib2.Interval;
+import net.imglib2.realtransform.AffineTransform3D;
+
+class VisibilityUtils
+{
+	/**
+	 * Compute a list of sources are currently visible on screen.
+	 * <p>
+	 * This means that the sources
+	 * <ul>
+	 *     <li>are visible in the given {@code ViewerState}, and,</li>
+	 *     <li>when transformed to viewer coordinates, overlap the screen area.</li>
+	 * </ul>
+	 * The returned list of sources is sorted by {@code viewerState.sourceOrder()}.
+	 *
+	 * @param viewerState
+	 * 		specifies sources, transform, and current timepoint
+	 * @param screenScale
+	 * 		specifies screen size and scale transform
+	 * @param result
+	 * 		list of currently visible sources is stored here
+	 */
+	static void computeVisibleSourcesOnScreen(
+			final ViewerState viewerState,
+			final ScreenScales.ScreenScale screenScale,
+			final List< SourceAndConverter< ? > > result )
+	{
+		result.clear();
+
+		final int screenMinX = 0;
+		final int screenMinY = 0;
+		final int screenMaxX = screenScale.width() - 1;
+		final int screenMaxY = screenScale.height() - 1;
+
+		final AffineTransform3D screenTransform = viewerState.getViewerTransform();
+		screenTransform.preConcatenate( screenScale.scaleTransform() );
+
+		final AffineTransform3D sourceToScreen = new AffineTransform3D();
+		final double[] sourceMin = new double[ 3 ];
+		final double[] sourceMax = new double[ 3 ];
+
+		final Set< SourceAndConverter< ? > > sources = viewerState.getVisibleAndPresentSources();
+		final int t = viewerState.getCurrentTimepoint();
+		final double expand = viewerState.getInterpolation() == Interpolation.NEARESTNEIGHBOR ? 0.5 : 1.0;
+
+		for ( final SourceAndConverter< ? > source : sources )
+		{
+			if( !source.getSpimSource().doBoundingBoxCulling() )
+			{
+				result.add( source );
+				continue;
+			}
+
+			final Source< ? > spimSource = source.getSpimSource();
+			final int level = MipmapTransforms.getBestMipMapLevel( screenTransform, spimSource, t );
+			spimSource.getSourceTransform( t, level, sourceToScreen );
+			sourceToScreen.preConcatenate( screenTransform );
+
+			final Interval interval = spimSource.getSource( t, level );
+			for ( int d = 0; d < 3; d++ )
+			{
+				sourceMin[ d ] = interval.realMin( d ) - expand;
+				sourceMax[ d ] = interval.realMax( d ) + expand;
+			}
+			final FinalRealInterval bb = sourceToScreen.estimateBounds( new FinalRealInterval( sourceMin, sourceMax ) );
+
+			if ( bb.realMax( 0 ) >= screenMinX
+					&& bb.realMin( 0 ) <= screenMaxX
+					&& bb.realMax( 1 ) >= screenMinY
+					&& bb.realMin( 1 ) <= screenMaxY
+					&& bb.realMax( 2 ) >= 0
+					&& bb.realMin( 2 ) <= 0 )
+			{
+				result.add( source );
+			}
+		}
+
+		result.sort( viewerState.sourceOrder() );
+	}
+	// TODO: Eventually, for thousands of sources, this could be moved to ViewerState,
+	//  in order to avoid creating a new intermediate HashSet for every
+	//  viewerState.getVisibleAndPresentSources().
+	//  However, other issues will become bottlenecks before that, e.g.,
+	//  copying source list when taking snapshots of ViewerState every frame,
+	//  painting MultiBoxOverlay, etc.
+}
diff --git a/src/main/java/bdv/viewer/render/VolatileHierarchyProjector.java b/src/main/java/bdv/viewer/render/VolatileHierarchyProjector.java
index bbe86420e90b14832f80d90805edc1fe6d3647f1..66387e69b7a8b9e95c16fc21e137df2fc7ca06a4 100644
--- a/src/main/java/bdv/viewer/render/VolatileHierarchyProjector.java
+++ b/src/main/java/bdv/viewer/render/VolatileHierarchyProjector.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -37,10 +36,7 @@ import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
-
-import net.imglib2.Cursor;
 import net.imglib2.FinalInterval;
-import net.imglib2.IterableInterval;
 import net.imglib2.RandomAccess;
 import net.imglib2.RandomAccessible;
 import net.imglib2.RandomAccessibleInterval;
@@ -48,12 +44,9 @@ import net.imglib2.Volatile;
 import net.imglib2.cache.iotiming.CacheIoTiming;
 import net.imglib2.cache.iotiming.IoStatistics;
 import net.imglib2.converter.Converter;
-import net.imglib2.img.Img;
-import net.imglib2.img.array.ArrayImgs;
-import net.imglib2.type.numeric.NumericType;
-import net.imglib2.type.numeric.integer.ByteType;
-import net.imglib2.ui.AbstractInterruptibleProjector;
-import net.imglib2.ui.util.StopWatch;
+import net.imglib2.type.operators.SetZero;
+import net.imglib2.util.Intervals;
+import net.imglib2.util.StopWatch;
 import net.imglib2.view.Views;
 
 /**
@@ -61,75 +54,87 @@ import net.imglib2.view.Views;
  * {@link #map()} call, the projector has a {@link #isValid() state} that
  * signalizes whether all projected pixels were perfect.
  *
- * @author Stephan Saalfeld &lt;saalfeld@mpi-cbg.de&gt;
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Stephan Saalfeld
+ * @author Tobias Pietzsch
  */
-public class VolatileHierarchyProjector< A extends Volatile< ? >, B extends NumericType< B > > extends AbstractInterruptibleProjector< A, B > implements VolatileProjector
+public class VolatileHierarchyProjector< A extends Volatile< ? >, B extends SetZero > implements VolatileProjector
 {
-	protected final ArrayList< RandomAccessible< A > > sources = new ArrayList<>();
-
-	private final byte[] maskArray;
-
-	protected final Img< ByteType > mask;
-
-	protected volatile boolean valid = false;
+	/**
+	 * A converter from the source pixel type to the target pixel type.
+	 */
+	private final Converter< ? super A, B > converter;
 
-	protected int numInvalidLevels;
+	/**
+	 * The target interval. Pixels of the target interval should be set by
+	 * {@link #map}
+	 */
+	private final RandomAccessibleInterval< B > target;
 
 	/**
-	 * Extends of the source to be used for mapping.
+	 * List of source resolutions starting with the optimal resolution at index
+	 * 0. During each {@link #map(boolean)}, for every pixel, resolution levels
+	 * are successively queried until a valid pixel is found.
 	 */
-	protected final FinalInterval sourceInterval;
+	private final List< RandomAccessible< A > > sources;
 
 	/**
-	 * Target width
+	 * Records, for every target pixel, the best (smallest index) source
+	 * resolution level that has provided a valid value. Only better (lower
+	 * index) resolutions are re-tried in successive {@link #map(boolean)}
+	 * calls.
 	 */
-	protected final int width;
+	private final byte[] mask;
 
 	/**
-	 * Target height
+	 * {@code true} iff all target pixels were rendered with valid data from the
+	 * optimal resolution level (level {@code 0}).
 	 */
-	protected final int height;
+	private volatile boolean valid = false;
 
 	/**
-	 * Steps for carriage return. Typically -{@link #width}
+	 * How many levels (starting from level {@code 0}) have to be re-rendered in
+	 * the next rendering pass, i.e., {@code map()} call.
 	 */
-	protected final int cr;
+	private int numInvalidLevels;
 
 	/**
-	 * A reference to the target image as an iterable. Used for source-less
-	 * operations such as clearing its content.
+	 * Source interval which will be used for rendering. This is the 2D target
+	 * interval expanded to source dimensionality (usually 3D) with
+	 * {@code min=max=0} in the additional dimensions.
 	 */
-	protected final IterableInterval< B > iterableTarget;
+	private final FinalInterval sourceInterval;
 
 	/**
 	 * Number of threads to use for rendering
 	 */
-	protected final int numThreads;
+	private final int numThreads;
 
-	protected final ExecutorService executorService;
+	/**
+	 * Executor service to be used for rendering
+	 */
+	private final ExecutorService executorService;
 
 	/**
 	 * Time needed for rendering the last frame, in nano-seconds.
 	 * This does not include time spent in blocking IO.
 	 */
-	protected long lastFrameRenderNanoTime;
+	private long lastFrameRenderNanoTime;
 
 	/**
 	 * Time spent in blocking IO rendering the last frame, in nano-seconds.
 	 */
-	protected long lastFrameIoNanoTime; // TODO move to derived implementation for local sources only
+	private long lastFrameIoNanoTime; // TODO move to derived implementation for local sources only
 
 	/**
 	 * temporary variable to store the number of invalid pixels in the current
 	 * rendering pass.
 	 */
-	protected final AtomicInteger numInvalidPixels = new AtomicInteger();
+	private final AtomicInteger numInvalidPixels = new AtomicInteger();
 
 	/**
-	 * Flag to indicate that someone is trying to interrupt rendering.
+	 * Flag to indicate that someone is trying to {@link #cancel()} rendering.
 	 */
-	protected final AtomicBoolean interrupted = new AtomicBoolean();
+	private final AtomicBoolean canceled = new AtomicBoolean();
 
 	public VolatileHierarchyProjector(
 			final List< ? extends RandomAccessible< A > > sources,
@@ -149,27 +154,21 @@ public class VolatileHierarchyProjector< A extends Volatile< ? >, B extends Nume
 			final int numThreads,
 			final ExecutorService executorService )
 	{
-		super( Math.max( 2, sources.get( 0 ).numDimensions() ), converter, target );
-
-		this.sources.addAll( sources );
+		this.converter = converter;
+		this.target = target;
+		this.sources = new ArrayList<>( sources );
 		numInvalidLevels = sources.size();
+		mask = maskArray;
 
-		this.maskArray = maskArray;
-		mask = ArrayImgs.bytes( maskArray, target.dimension( 0 ), target.dimension( 1 ) );
-
-		iterableTarget = Views.iterable( target );
-
-		for ( int d = 2; d < min.length; ++d )
-			min[ d ] = max[ d ] = 0;
-
+		final int n = Math.max( 2, sources.get( 0 ).numDimensions() );
+		final long[] min = new long[ n ];
+		final long[] max = new long[ n ];
+		min[ 0 ] = target.min( 0 );
 		max[ 0 ] = target.max( 0 );
+		min[ 1 ] = target.min( 1 );
 		max[ 1 ] = target.max( 1 );
 		sourceInterval = new FinalInterval( min, max );
 
-		width = ( int )target.dimension( 0 );
-		height = ( int )target.dimension( 1 );
-		cr = -width;
-
 		this.numThreads = numThreads;
 		this.executorService = executorService;
 
@@ -180,7 +179,7 @@ public class VolatileHierarchyProjector< A extends Volatile< ? >, B extends Nume
 	@Override
 	public void cancel()
 	{
-		interrupted.set( true );
+		canceled.set( true );
 	}
 
 	@Override
@@ -206,164 +205,175 @@ public class VolatileHierarchyProjector< A extends Volatile< ? >, B extends Nume
 	 */
 	public void clearMask()
 	{
-		Arrays.fill( maskArray, 0, ( int ) mask.size(), Byte.MAX_VALUE );
+		final int size = ( int ) Intervals.numElements( target );
+		Arrays.fill( mask, 0, size, Byte.MAX_VALUE );
 		numInvalidLevels = sources.size();
 	}
 
 	/**
 	 * Clear target pixels that were never written.
 	 */
-	protected void clearUntouchedTargetPixels()
+	private void clearUntouchedTargetPixels()
 	{
-		final Cursor< ByteType > maskCursor = mask.cursor();
-		for ( final B t : iterableTarget )
-			if ( maskCursor.next().get() == Byte.MAX_VALUE )
-				t.setZero();
-	}
-
-	@Override
-	public boolean map()
-	{
-		return map( true );
+		final int[] data = ProjectorUtils.getARGBArrayImgData( target );
+		if ( data != null )
+		{
+			final int size = ( int ) Intervals.numElements( target );
+			for ( int i = 0; i < size; ++i )
+				if ( mask[ i ] == Byte.MAX_VALUE )
+					data[ i ] = 0;
+		}
+		else
+		{
+			int i = 0;
+			for ( final B t : Views.flatIterable( target ) )
+				if ( mask[ i++ ] == Byte.MAX_VALUE )
+					t.setZero();
+		}
 	}
 
 	@Override
 	public boolean map( final boolean clearUntouchedTargetPixels )
 	{
-		interrupted.set( false );
+		if ( canceled.get() )
+			return false;
+
+		valid = false;
 
-		final StopWatch stopWatch = new StopWatch();
-		stopWatch.start();
+		final StopWatch stopWatch = StopWatch.createAndStart();
 		final IoStatistics iostat = CacheIoTiming.getIoStatistics();
 		final long startTimeIo = iostat.getIoNanoTime();
 		final long startTimeIoCumulative = iostat.getCumulativeIoNanoTime();
-//		final long startIoBytes = iostat.getIoBytes();
-
-		final int numTasks;
-		if ( numThreads > 1 )
-		{
-			numTasks = Math.min( numThreads * 10, height );
-		}
-		else
-			numTasks = 1;
-		final double taskHeight = ( double )height / numTasks;
-
-		int i;
 
-		valid = false;
+		final int targetHeight = ( int ) target.dimension( 1 );
+		final int numTasks = numThreads <= 1 ? 1 : Math.min( numThreads * 10, targetHeight );
+		final double taskHeight = ( double ) targetHeight / numTasks;
+		final int[] taskStartHeights = new int[ numTasks + 1 ];
+		for ( int i = 0; i < numTasks; ++i )
+			taskStartHeights[ i ] = ( int ) ( i * taskHeight );
+		taskStartHeights[ numTasks ] = targetHeight;
 
 		final boolean createExecutor = ( executorService == null );
 		final ExecutorService ex = createExecutor ? Executors.newFixedThreadPool( numThreads ) : executorService;
-		for ( i = 0; i < numInvalidLevels && !valid; ++i )
+		try
 		{
-			final byte iFinal = ( byte ) i;
-
-			valid = true;
-			numInvalidPixels.set( 0 );
-
-			final ArrayList< Callable< Void > > tasks = new ArrayList<>( numTasks );
-			for ( int taskNum = 0; taskNum < numTasks; ++taskNum )
+			/*
+			 * After the for loop, resolutionLevel is the highest (coarsest)
+			 * resolution for which all pixels could be filled from valid data. This
+			 * means that in the next pass, i.e., map() call, levels up to
+			 * resolutionLevel have to be re-rendered.
+			 */
+			int resolutionLevel;
+			for ( resolutionLevel = 0; resolutionLevel < numInvalidLevels; ++resolutionLevel )
 			{
-				final int myOffset = width * ( int ) ( taskNum * taskHeight );
-				final long myMinY = min[ 1 ] + ( int ) ( taskNum * taskHeight );
-				final int myHeight = ( int ) ( ( (taskNum == numTasks - 1 ) ? height : ( int ) ( ( taskNum + 1 ) * taskHeight ) ) - myMinY - min[ 1 ] );
-
-				final Callable< Void > r = new Callable< Void >()
+				final List< Callable< Void > > tasks = new ArrayList<>( numTasks );
+				for ( int i = 0; i < numTasks; ++i )
+					tasks.add( createMapTask( ( byte ) resolutionLevel, taskStartHeights[ i ], taskStartHeights[ i + 1 ] ) );
+				numInvalidPixels.set( 0 );
+				try
 				{
-					@Override
-					public Void call()
-					{
-						if ( interrupted.get() )
-							return null;
-
-						final RandomAccess< B > targetRandomAccess = target.randomAccess( target );
-						final Cursor< ByteType > maskCursor = mask.cursor();
-						final RandomAccess< A > sourceRandomAccess = sources.get( iFinal ).randomAccess( sourceInterval );
-						int myNumInvalidPixels = 0;
-
-						final long[] smin = new long[ n ];
-						System.arraycopy( min, 0, smin, 0, n );
-						smin[ 1 ] = myMinY;
-						sourceRandomAccess.setPosition( smin );
-
-						targetRandomAccess.setPosition( min[ 0 ], 0 );
-						targetRandomAccess.setPosition( myMinY, 1 );
-
-						maskCursor.jumpFwd( myOffset );
-
-						for ( int y = 0; y < myHeight; ++y )
-						{
-							if ( interrupted.get() )
-								return null;
-
-							for ( int x = 0; x < width; ++x )
-							{
-								final ByteType m = maskCursor.next();
-								if ( m.get() > iFinal )
-								{
-									final A a = sourceRandomAccess.get();
-									final boolean v = a.isValid();
-									if ( v )
-									{
-										converter.convert( a, targetRandomAccess.get() );
-										m.set( iFinal );
-									}
-									else
-										++myNumInvalidPixels;
-								}
-								sourceRandomAccess.fwd( 0 );
-								targetRandomAccess.fwd( 0 );
-							}
-							++smin[ 1 ];
-							sourceRandomAccess.setPosition( smin );
-							targetRandomAccess.move( cr, 0 );
-							targetRandomAccess.fwd( 1 );
-						}
-						numInvalidPixels.addAndGet( myNumInvalidPixels );
-						if ( myNumInvalidPixels != 0 )
-							valid = false;
-						return null;
-					}
-				};
-				tasks.add( r );
-			}
-			try
-			{
-				ex.invokeAll( tasks );
-			}
-			catch ( final InterruptedException e )
-			{
-				Thread.currentThread().interrupt();
-			}
-			if ( interrupted.get() )
-			{
-//				System.out.println( "interrupted" );
-				if ( createExecutor )
-					ex.shutdown();
-				return false;
+					ex.invokeAll( tasks );
+				}
+				catch ( final InterruptedException e )
+				{
+					Thread.currentThread().interrupt();
+				}
+				if ( canceled.get() )
+					return false;
+				if ( numInvalidPixels.get() == 0 )
+					// if this pass was all valid
+					numInvalidLevels = resolutionLevel;
 			}
-//			System.out.println( "numInvalidPixels(" + i + ") = " + numInvalidPixels );
 		}
-		if ( createExecutor )
-			ex.shutdown();
+		finally
+		{
+			if ( createExecutor )
+				ex.shutdown();
+		}
 
-		if ( clearUntouchedTargetPixels && !interrupted.get() )
+		if ( clearUntouchedTargetPixels && !canceled.get() )
 			clearUntouchedTargetPixels();
 
 		final long lastFrameTime = stopWatch.nanoTime();
-//		final long numIoBytes = iostat.getIoBytes() - startIoBytes;
 		lastFrameIoNanoTime = iostat.getIoNanoTime() - startTimeIo;
 		lastFrameRenderNanoTime = lastFrameTime - ( iostat.getCumulativeIoNanoTime() - startTimeIoCumulative ) / numThreads;
-
 //		System.out.println( "lastFrameTime = " + lastFrameTime / 1000000 );
 //		System.out.println( "lastFrameRenderNanoTime = " + lastFrameRenderNanoTime / 1000000 );
 
-		if ( valid )
-			numInvalidLevels = i - 1;
 		valid = numInvalidLevels == 0;
 
-//		System.out.println( "Mapping complete after " + ( s + 1 ) + " levels." );
+		return !canceled.get();
+	}
+
+	/**
+	 * @return a {@code Callable} that runs
+	 * {@code map(resolutionIndex, startHeight, endHeight)}
+	 */
+	private Callable< Void > createMapTask( final byte resolutionIndex, final int startHeight, final int endHeight )
+	{
+		return Executors.callable( () -> map( resolutionIndex, startHeight, endHeight ), null );
+	}
+
+	/**
+	 * Copy lines from {@code y = startHeight} up to {@code endHeight}
+	 * (exclusive) from source {@code resolutionIndex} to target. Check after
+	 * each line whether rendering was {@link #cancel() canceled}.
+	 * <p>
+	 * Only valid source pixels with a current mask value
+	 * {@code mask>resolutionIndex} are copied to target, and their mask value
+	 * is set to {@code mask=resolutionIndex}. Invalid source pixels are
+	 * ignored. Pixels with {@code mask<=resolutionIndex} are ignored, because
+	 * they have already been written to target during a previous pass.
+	 * <p>
+	 *
+	 * @param resolutionIndex
+	 *     index of source resolution level
+	 * @param startHeight
+	 *     start of line range to copy (relative to target min coordinate)
+	 * @param endHeight
+	 *     end (exclusive) of line range to copy (relative to target min
+	 *     coordinate)
+	 */
+	private void map( final byte resolutionIndex, final int startHeight, final int endHeight )
+	{
+		if ( canceled.get() )
+			return;
+
+		final RandomAccess< B > targetRandomAccess = target.randomAccess( target );
+		final RandomAccess< A > sourceRandomAccess = sources.get( resolutionIndex ).randomAccess( sourceInterval );
+		final int width = ( int ) target.dimension( 0 );
+		final long[] smin = Intervals.minAsLongArray( sourceInterval );
+		int myNumInvalidPixels = 0;
+
+		final int targetMin = ( int ) target.min( 1 );
+		for ( int y = startHeight; y < endHeight; ++y )
+		{
+			if ( canceled.get() )
+				return;
+
+			smin[ 1 ] = y + targetMin;
+			sourceRandomAccess.setPosition( smin );
+			targetRandomAccess.setPosition( smin );
+			final int mi = y * width;
+			for ( int x = 0; x < width; ++x )
+			{
+				if ( mask[ mi + x ] > resolutionIndex )
+				{
+					final A a = sourceRandomAccess.get();
+					final boolean v = a.isValid();
+					if ( v )
+					{
+						converter.convert( a, targetRandomAccess.get() );
+						mask[ mi + x ] = resolutionIndex;
+					}
+					else
+						++myNumInvalidPixels;
+				}
+				sourceRandomAccess.fwd( 0 );
+				targetRandomAccess.fwd( 0 );
+			}
+		}
 
-		return !interrupted.get();
+		numInvalidPixels.addAndGet( myNumInvalidPixels );
 	}
 }
diff --git a/src/main/java/bdv/viewer/render/VolatileProjector.java b/src/main/java/bdv/viewer/render/VolatileProjector.java
index 18f61203e6e4848d1ac133093065bae25197fb9a..4e94698a5148b3fc9067b770cf39686ad8ffdd15 100644
--- a/src/main/java/bdv/viewer/render/VolatileProjector.java
+++ b/src/main/java/bdv/viewer/render/VolatileProjector.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -29,10 +28,16 @@
  */
 package bdv.viewer.render;
 
-import net.imglib2.Volatile;
-import net.imglib2.ui.InterruptibleProjector;
-
-public interface VolatileProjector extends InterruptibleProjector
+/**
+ * Render a 2D target copying pixels from a nD source.
+ * <p>
+ * Rendering can be interrupted, in which case {@link #map} will return false.
+ * Also, the rendering time for the last {@link #map} can be queried.
+ *
+ * @author Tobias Pietzsch
+ * @author Stephan Saalfeld
+ */
+public interface VolatileProjector
 {
 	/**
 	 * Render the target image.
@@ -41,10 +46,27 @@ public interface VolatileProjector extends InterruptibleProjector
 	 * @return true if rendering was completed (all target pixels written).
 	 *         false if rendering was interrupted.
 	 */
-	public boolean map( boolean clearUntouchedTargetPixels );
+	boolean map( boolean clearUntouchedTargetPixels );
+
+	default boolean map()
+	{
+		return map( true );
+	}
+
+	/**
+	 * Abort {@link #map()} if it is currently running.
+	 */
+	void cancel();
+
+	/**
+	 * How many nano-seconds did the last {@link #map()} take.
+	 *
+	 * @return time needed for rendering the last frame, in nano-seconds.
+	 */
+	long getLastFrameRenderNanoTime();
 
 	/**
-	 * @return true if all mapped pixels were {@link Volatile#isValid() valid}.
+	 * @return true if all mapped pixels were valid.
 	 */
-	public boolean isValid();
+	boolean isValid();
 }
diff --git a/src/main/java/bdv/viewer/render/awt/BufferedImageOverlayRenderer.java b/src/main/java/bdv/viewer/render/awt/BufferedImageOverlayRenderer.java
new file mode 100644
index 0000000000000000000000000000000000000000..160bc2fc5ab5d9f065f76d94572f0516dd72889b
--- /dev/null
+++ b/src/main/java/bdv/viewer/render/awt/BufferedImageOverlayRenderer.java
@@ -0,0 +1,177 @@
+/*
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.viewer.render.awt;
+
+import bdv.util.TripleBuffer;
+import bdv.util.TripleBuffer.ReadableBuffer;
+import java.awt.Graphics;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.image.BufferedImage;
+import net.imglib2.realtransform.AffineTransform3D;
+import bdv.viewer.OverlayRenderer;
+import bdv.viewer.render.RenderTarget;
+import bdv.viewer.TransformListener;
+import org.scijava.listeners.Listeners;
+
+/**
+ * {@link OverlayRenderer} drawing a {@link BufferedImage}, scaled to fill the
+ * canvas. It can be used as a {@link RenderTarget}, such that the
+ * {@link BufferedImageRenderResult} to draw is set by a renderer.
+ *
+ * @author Tobias Pietzsch
+ */
+public class BufferedImageOverlayRenderer implements OverlayRenderer, RenderTarget< BufferedImageRenderResult >
+{
+	private final TripleBuffer< BufferedImageRenderResult > tripleBuffer;
+
+	/**
+	 * These listeners will be notified about the transform that is associated
+	 * to the currently rendered image. This is intended for example for
+	 * {@link OverlayRenderer}s that need to exactly match the transform of
+	 * their overlaid content to the transform of the image.
+	 */
+	private final Listeners.List< TransformListener< AffineTransform3D > > paintedTransformListeners;
+
+	private final AffineTransform3D paintedTransform;
+
+	/**
+	 * The current canvas width.
+	 */
+	private volatile int width;
+
+	/**
+	 * The current canvas height.
+	 */
+	private volatile int height;
+
+	public BufferedImageOverlayRenderer()
+	{
+		tripleBuffer = new TripleBuffer<>( BufferedImageRenderResult::new );
+		width = 0;
+		height = 0;
+		paintedTransform = new AffineTransform3D();
+		paintedTransformListeners = new Listeners.SynchronizedList<>( l -> l.transformChanged( paintedTransform ) );
+	}
+
+	@Override
+	public BufferedImageRenderResult getReusableRenderResult()
+	{
+		return tripleBuffer.getWritableBuffer();
+	}
+
+	@Override
+	public BufferedImageRenderResult createRenderResult()
+	{
+		return new BufferedImageRenderResult();
+	}
+
+	/**
+	 * Set the {@code RenderResult} that is to be drawn on the canvas.
+	 *
+	 * @param result
+	 *            image to draw (may be null).
+	 */
+	@Override
+	public synchronized void setRenderResult( final BufferedImageRenderResult result )
+	{
+		tripleBuffer.doneWriting( result );
+	}
+
+	@Override
+	public int getWidth()
+	{
+		return width;
+	}
+
+	@Override
+	public int getHeight()
+	{
+		return height;
+	}
+
+	@Override
+	public void drawOverlays( final Graphics g )
+	{
+		final ReadableBuffer< BufferedImageRenderResult > rb = tripleBuffer.getReadableBuffer();
+		final BufferedImageRenderResult result = rb.getBuffer();
+		final BufferedImage image = result != null ? result.getBufferedImage() : null;
+		if ( image != null )
+		{
+//			( ( Graphics2D ) g ).setRenderingHint( RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR );
+			( ( Graphics2D ) g ).setRenderingHint( RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR );
+			( ( Graphics2D ) g ).setRenderingHint( RenderingHints.KEY_ALPHA_INTERPOLATION, RenderingHints.VALUE_ALPHA_INTERPOLATION_SPEED );
+			( ( Graphics2D ) g ).setRenderingHint( RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF );
+			( ( Graphics2D ) g ).setRenderingHint( RenderingHints.KEY_COLOR_RENDERING, RenderingHints.VALUE_COLOR_RENDER_SPEED );
+			( ( Graphics2D ) g ).setRenderingHint( RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_SPEED );
+
+			final double scaleFactor = result.getScaleFactor();
+			final int w = Math.max( width, ( int ) ( image.getWidth() / scaleFactor + 0.5 ) );
+			final int h = Math.max( height, ( int ) ( image.getHeight() / scaleFactor + 0.5 ) );
+			g.drawImage( image, 0, 0, w, h, null );
+
+			if ( rb.isUpdated() )
+			{
+				paintedTransform.set( result.getViewerTransform() );
+				paintedTransformListeners.list.forEach( listener -> listener.transformChanged( paintedTransform ) );
+			}
+		}
+	}
+
+	@Override
+	public void setCanvasSize( final int width, final int height )
+	{
+		this.width = width;
+		this.height = height;
+	}
+
+	/**
+	 * Add/remove {@code TransformListener}s to notify about viewer transformation
+	 * changes. Listeners will be notified when a new image has been rendered
+	 * (immediately before that image is displayed) with the viewer transform
+	 * used to render that image.
+	 */
+	public Listeners< TransformListener< AffineTransform3D > > transformListeners()
+	{
+		return paintedTransformListeners;
+	}
+
+	/**
+	 * DON'T USE THIS.
+	 * <p>
+	 * This is a work around for JDK bug
+	 * https://bugs.openjdk.java.net/browse/JDK-8029147 which leads to
+	 * ViewerPanel not being garbage-collected when ViewerFrame is closed. So
+	 * instead we need to manually let go of resources...
+	 */
+	public void kill()
+	{
+		tripleBuffer.clear();
+	}
+}
diff --git a/src/main/java/bdv/viewer/render/awt/BufferedImageRenderResult.java b/src/main/java/bdv/viewer/render/awt/BufferedImageRenderResult.java
new file mode 100644
index 0000000000000000000000000000000000000000..4206a0d95136ce00d1f2ea984be54251ddaab250
--- /dev/null
+++ b/src/main/java/bdv/viewer/render/awt/BufferedImageRenderResult.java
@@ -0,0 +1,129 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.viewer.render.awt;
+
+import bdv.util.AWTUtils;
+import bdv.viewer.render.RenderResult;
+import java.awt.geom.AffineTransform;
+import java.awt.image.AffineTransformOp;
+import java.awt.image.BufferedImage;
+import net.imglib2.Interval;
+import net.imglib2.RandomAccessibleInterval;
+import net.imglib2.display.screenimage.awt.ARGBScreenImage;
+import net.imglib2.realtransform.AffineTransform3D;
+import net.imglib2.type.numeric.ARGBType;
+
+public class BufferedImageRenderResult implements RenderResult
+{
+	private final AffineTransform3D viewerTransform = new AffineTransform3D();
+
+	private int width;
+	private int height;
+
+	private int[] data = new int[ 0 ];
+
+	private ARGBScreenImage screenImage;
+
+	private BufferedImage bufferedImage;
+
+	private double scaleFactor;
+
+	@Override
+	public void init( final int width, final int height )
+	{
+		if ( this.width == width && this.height == height )
+			return;
+
+		this.width = width;
+		this.height = height;
+
+		if ( data.length < width * height )
+			data = new int[ width * height ];
+
+		screenImage = new ARGBScreenImage( width, height, data );
+		bufferedImage = AWTUtils.getBufferedImage( screenImage );;
+	}
+
+	@Override
+	public RandomAccessibleInterval< ARGBType > getTargetImage()
+	{
+		return screenImage;
+	}
+
+	public BufferedImage getBufferedImage()
+	{
+		return bufferedImage;
+	}
+
+	@Override
+	public AffineTransform3D getViewerTransform()
+	{
+		return viewerTransform;
+	}
+
+	@Override
+	public double getScaleFactor()
+	{
+		return scaleFactor;
+	}
+
+	@Override
+	public void setScaleFactor( final double scaleFactor )
+	{
+		this.scaleFactor = scaleFactor;
+	}
+
+	@Override
+	public void setUpdated()
+	{
+		// ignored. BufferedImage is always up-to-date
+	}
+
+	@Override
+	public void patch( final RenderResult patch, final Interval interval, final double ox, final double oy )
+	{
+		final BufferedImageRenderResult biresult = ( BufferedImageRenderResult ) patch;
+
+		final double s = scaleFactor / patch.getScaleFactor();
+		final double tx = ox - interval.min( 0 );
+		final double ty = oy - interval.min( 1 );
+		final AffineTransform transform = new AffineTransform( s, 0, 0, s, tx, ty );
+		final AffineTransformOp op = new AffineTransformOp( transform, AffineTransformOp.TYPE_NEAREST_NEIGHBOR );
+		op.filter( biresult.getBufferedImage(), subImage( interval ) );
+	}
+
+	private BufferedImage subImage( final Interval interval )
+	{
+		final int x = ( int ) interval.min( 0 );
+		final int y = ( int ) interval.min( 1 );
+		final int w = ( int ) interval.dimension( 0 );
+		final int h = ( int ) interval.dimension( 1 );
+		return bufferedImage.getSubimage( x, y, w, h );
+	}
+}
diff --git a/src/main/java/bdv/viewer/state/SourceGroup.java b/src/main/java/bdv/viewer/state/SourceGroup.java
index 5925b4efba4c03f6755eb7a2424d90b6f770209f..3d1d19baeec0f6c66a1ac8770b7566d3594dfcda 100644
--- a/src/main/java/bdv/viewer/state/SourceGroup.java
+++ b/src/main/java/bdv/viewer/state/SourceGroup.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -29,6 +28,8 @@
  */
 package bdv.viewer.state;
 
+import bdv.viewer.ViewerState;
+import java.util.Collections;
 import java.util.SortedSet;
 import java.util.TreeSet;
 
@@ -37,11 +38,16 @@ import bdv.viewer.DisplayMode;
 /**
  * TODO
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
+@Deprecated
 public class SourceGroup
 {
-	protected final TreeSet< Integer > sourceIds;
+	protected final SortedSet< Integer > sourceIds;
+
+	private final ViewerState state;
+
+	private final bdv.viewer.SourceGroup handle;
 
 	protected String name;
 
@@ -56,9 +62,18 @@ public class SourceGroup
 	 */
 	protected boolean isCurrent;
 
+	public SourceGroup( final ViewerState state, final bdv.viewer.SourceGroup handle )
+	{
+		this.state = state;
+		this.handle = handle;
+		sourceIds = Collections.synchronizedSortedSet( new TreeSet<>() );
+	}
+
 	public SourceGroup( final String name )
 	{
-		sourceIds = new TreeSet<>();
+		this.state = null;
+		this.handle = null;
+		sourceIds = Collections.synchronizedSortedSet( new TreeSet<>() );
 		this.name = name;
 		isActive = true;
 		isCurrent = false;
@@ -66,10 +81,15 @@ public class SourceGroup
 
 	protected SourceGroup( final SourceGroup g )
 	{
-		sourceIds = new TreeSet<>( g.sourceIds );
-		name = g.name;
-		isActive = g.isActive;
-		isCurrent = g.isCurrent;
+		this.state = null;
+		this.handle = null;
+		synchronized ( g.getSourceIds() )
+		{
+			sourceIds = Collections.synchronizedSortedSet( new TreeSet<>( g.getSourceIds() ) );
+		}
+		name = g.getName();
+		isActive = g.isActive();
+		isCurrent = g.isCurrent();
 	}
 
 	public SourceGroup copy()
@@ -79,26 +99,49 @@ public class SourceGroup
 
 	public void addSource( final int sourceId )
 	{
-		sourceIds.add( sourceId );
+		if ( handle == null )
+			sourceIds.add( sourceId );
+		else
+		{
+			state.addSourceToGroup( state.getSources().get( sourceId ), handle );
+			sourceIds.clear();
+			state.getSourcesInGroup( handle ).forEach( source -> sourceIds.add( state.getSources().indexOf( source ) ) );
+		}
 	}
 
 	public void removeSource( final int sourceId )
 	{
-		sourceIds.remove( sourceId );
+		if ( handle == null )
+			sourceIds.remove( sourceId );
+		else
+		{
+			state.removeSourceFromGroup( state.getSources().get( sourceId ), handle );
+			sourceIds.clear();
+			state.getSourcesInGroup( handle ).forEach( source -> sourceIds.add( state.getSources().indexOf( source ) ) );
+		}
 	}
 
 	public SortedSet< Integer > getSourceIds()
 	{
+		if ( handle != null )
+		{
+			sourceIds.clear();
+			state.getSourcesInGroup( handle ).forEach( source -> sourceIds.add( state.getSources().indexOf( source ) ) );
+		}
 		return sourceIds;
 	}
 
 	public String getName()
 	{
+		if ( handle != null )
+			name = state.getGroupName( handle );
 		return name;
 	}
 
 	public void setName( final String name )
 	{
+		if ( handle != null )
+			state.setGroupName( handle, name );
 		this.name = name;
 	}
 
@@ -109,6 +152,8 @@ public class SourceGroup
 	 */
 	public boolean isActive()
 	{
+		if ( handle != null )
+			isActive = state.isGroupActive( handle );
 		return isActive;
 	}
 
@@ -117,6 +162,8 @@ public class SourceGroup
 	 */
 	public void setActive( final boolean isActive )
 	{
+		if ( handle != null )
+			state.setGroupActive( handle, isActive );
 		this.isActive = isActive;
 	}
 
@@ -127,6 +174,8 @@ public class SourceGroup
 	 */
 	public boolean isCurrent()
 	{
+		if ( handle != null )
+			isCurrent = state.isCurrentGroup( handle );
 		return isCurrent;
 	}
 
@@ -135,6 +184,8 @@ public class SourceGroup
 	 */
 	public void setCurrent( final boolean isCurrent )
 	{
+		if ( handle != null && isCurrent )
+			state.setCurrentGroup( handle );
 		this.isCurrent = isCurrent;
 	}
 
diff --git a/src/main/java/bdv/viewer/state/SourceState.java b/src/main/java/bdv/viewer/state/SourceState.java
index 2e5b189f14aaec5aa1f288f5c3b29c133b694525..79034875ea7c4b6fae09a666149fb259a185c825 100644
--- a/src/main/java/bdv/viewer/state/SourceState.java
+++ b/src/main/java/bdv/viewer/state/SourceState.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -36,74 +35,45 @@ import bdv.viewer.SourceAndConverter;
 /**
  * Source with some attached state needed for rendering.
  */
+@Deprecated
 public class SourceState< T > extends SourceAndConverter< T >
 {
-	protected static class Data
-	{
-		/**
-		 * Whether the source is active (visible in  {@link DisplayMode#FUSED} mode).
-		 */
-		protected boolean isActive;
-
-		/**
-		 * Whether the source is current.
-		 */
-		protected boolean isCurrent;
-
-		public Data()
-		{
-			isActive = true;
-			isCurrent = false;
-		}
-
-		protected Data( final Data d )
-		{
-			isActive = d.isActive;
-			isCurrent = d.isCurrent;
-		}
-
-		public Data copy()
-		{
-			return new Data( this );
-		}
-	}
-
 	static class VolatileSourceState< T, V extends Volatile< T > > extends SourceState< V >
 	{
-		public VolatileSourceState( final SourceAndConverter< V > soc, final ViewerState owner, final Data data )
+		public VolatileSourceState( final SourceAndConverter< V > soc, final ViewerState owner, final SourceAndConverter< ? > handle )
 		{
-			super( soc, owner, data );
+			super( soc, owner, handle );
 		}
 
-		public static < T, V extends Volatile< T > > VolatileSourceState< T, V > create( final SourceAndConverter< V > soc, final ViewerState owner, final Data data )
+		public static < T, V extends Volatile< T > > VolatileSourceState< T, V > create( final SourceAndConverter< V > soc, final ViewerState owner, final SourceAndConverter< ? > handle )
 		{
 			if ( soc == null )
 				return null;
 			else
-				return new VolatileSourceState<>( soc, owner, data );
+				return new VolatileSourceState<>( soc, owner, handle );
 		}
 	}
 
 	final ViewerState owner;
 
-	final Data data;
+	final SourceAndConverter< ? > handle;
 
 	final VolatileSourceState< T, ? extends Volatile< T > > volatileSourceState;
 
 	public SourceState( final SourceAndConverter< T > soc, final ViewerState owner )
 	{
 		super( soc );
-		data = new Data();
 		this.owner = owner;
-		volatileSourceState = VolatileSourceState.create( soc.asVolatile(), owner, data );
+		handle = soc;
+		volatileSourceState = VolatileSourceState.create( soc.asVolatile(), owner, handle );
 	}
 
-	protected SourceState( final SourceAndConverter< T > soc, final ViewerState owner, final Data data )
+	protected SourceState( final SourceAndConverter< T > soc, final ViewerState owner, final SourceAndConverter< ? > handle )
 	{
 		super( soc );
-		this.data = data;
 		this.owner = owner;
-		volatileSourceState = VolatileSourceState.create( soc.asVolatile(), owner, data );
+		this.handle = handle;
+		volatileSourceState = VolatileSourceState.create( soc.asVolatile(), owner, handle );
 	}
 
 	/**
@@ -113,9 +83,9 @@ public class SourceState< T > extends SourceAndConverter< T >
 	protected SourceState( final SourceState< T > s, final ViewerState owner )
 	{
 		super( s );
-		data = s.data.copy();
 		this.owner = owner;
-		volatileSourceState = VolatileSourceState.create( s.volatileSourceAndConverter, owner, data );
+		handle = s.handle;
+		volatileSourceState = VolatileSourceState.create( s.volatileSourceAndConverter, owner, handle );
 	}
 
 	public SourceState< T > copy( final ViewerState owner )
@@ -130,7 +100,7 @@ public class SourceState< T > extends SourceAndConverter< T >
 	 */
 	public boolean isActive()
 	{
-		return data.isActive;
+		return owner.state.isSourceActive( handle );
 	}
 
 	/**
@@ -140,7 +110,7 @@ public class SourceState< T > extends SourceAndConverter< T >
 	{
 		synchronized ( owner )
 		{
-			data.isActive = isActive;
+			owner.state.setSourceActive( handle, isActive );
 		}
 	}
 
@@ -151,7 +121,7 @@ public class SourceState< T > extends SourceAndConverter< T >
 	 */
 	public boolean isCurrent()
 	{
-		return data.isCurrent;
+		return owner.state.isCurrentSource( handle );
 	}
 
 	/**
@@ -161,7 +131,7 @@ public class SourceState< T > extends SourceAndConverter< T >
 	{
 		synchronized ( owner )
 		{
-			data.isCurrent = isCurrent;
+			owner.state.setCurrentSource( handle );
 		}
 	}
 
@@ -178,5 +148,14 @@ public class SourceState< T > extends SourceAndConverter< T >
 	{
 		return volatileSourceState;
 	}
+
+	/**
+	 * Get the SourceAndConverter that this SourceState represents.
+	 * This handle is used to make modifications to the {@link bdv.viewer.ViewerState}
+	 */
+	public SourceAndConverter< ? > getHandle()
+	{
+		return handle;
+	}
 }
 
diff --git a/src/main/java/bdv/viewer/state/ViewerState.java b/src/main/java/bdv/viewer/state/ViewerState.java
index 1f1dc38fe31c7c79c95565e0ee12e6fdfcbcbcd3..75ac5fdc325883327f91a1a396f796e3068f3a30 100644
--- a/src/main/java/bdv/viewer/state/ViewerState.java
+++ b/src/main/java/bdv/viewer/state/ViewerState.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -29,141 +28,89 @@
  */
 package bdv.viewer.state;
 
-import static bdv.viewer.DisplayMode.FUSED;
-import static bdv.viewer.DisplayMode.SINGLE;
-import static bdv.viewer.Interpolation.NEARESTNEIGHBOR;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.SortedSet;
-import java.util.TreeSet;
-
 import bdv.util.MipmapTransforms;
 import bdv.viewer.DisplayMode;
 import bdv.viewer.Interpolation;
 import bdv.viewer.Source;
 import bdv.viewer.SourceAndConverter;
+import bdv.viewer.BasicViewerState;
+import bdv.viewer.SynchronizedViewerState;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
 import net.imglib2.realtransform.AffineTransform3D;
 
+import static bdv.viewer.DisplayMode.FUSED;
+import static bdv.viewer.DisplayMode.SINGLE;
+import static bdv.viewer.Interpolation.NEARESTNEIGHBOR;
+
 /**
  * Description of everything required to render the current image, such as the
  * current timepoint, the visible and current sources and groups respectively,
  * the viewer transformation, etc.
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
+@Deprecated
 public class ViewerState
 {
-	private final ArrayList< SourceState< ? > > sources;
-
-	/**
-	 * read-only view of {@link #sources}.
-	 */
-	private final List< SourceState< ? > > unmodifiableSources;
-
-	private final ArrayList< SourceGroup > groups;
-
-	/**
-	 * read-only view of {@link #groups}.
-	 */
-	private final List< SourceGroup > unmodifiableGroups;
-
-	/**
-	 * number of available timepoints.
-	 */
-	private int numTimepoints;
-
-	/**
-	 * Transformation set by the interactive viewer. Transforms from global
-	 * coordinate system to viewer coordinate system.
-	 */
-	private final AffineTransform3D viewerTransform;
-
-	/**
-	 * Which interpolation method is currently used to render the display.
-	 */
-	private Interpolation interpolation;
-
-	/**
-	 * Is the display mode <em>single-source</em>? In <em>single-source</em>
-	 * mode, only the current source (SPIM angle). Otherwise, in <em>fused</em>
-	 * mode, all active sources are blended.
-	 */
-//	protected boolean singleSourceMode;
-	/**
-	 * TODO
-	 */
-	private DisplayMode displayMode;
-
-	/**
-	 * The index of the current source.
-	 * (In single-source mode only the current source is shown.)
-	 */
-	private int currentSource;
-
-	/**
-	 * The index of the current group.
-	 * (In single-group mode only the sources in the current group are shown.)
-	 */
-	private int currentGroup;
+	public SynchronizedViewerState getState()
+	{
+		return state;
+	}
 
-	/**
-	 * which timepoint is currently shown.
-	 */
-	private int currentTimepoint;
+	final SynchronizedViewerState state;
 
 	public ViewerState( final List< SourceAndConverter< ? > > sources, final int numTimePoints )
 	{
-		this( sources, null, numTimePoints );
+		this( sources, new ArrayList<>(), numTimePoints );
 	}
 
 	/**
-	 *
 	 * @param sources
-	 *            the {@link SourceAndConverter sources} to display.
+	 * 		the {@link SourceAndConverter sources} to display.
 	 * @param numTimePoints
-	 *            number of available timepoints.
+	 * 		number of available timepoints.
 	 */
 	public ViewerState( final List< SourceAndConverter< ? > > sources, final List< SourceGroup > sourceGroups, final int numTimePoints )
 	{
-		this.sources = new ArrayList<>( sources.size() );
-		for ( final SourceAndConverter< ? > source : sources )
-			this.sources.add( SourceState.create( source, this ) );
-		unmodifiableSources = Collections.unmodifiableList( this.sources );
-		groups = ( sourceGroups == null ) ? new ArrayList<>() : new ArrayList<>( sourceGroups );
-		unmodifiableGroups = Collections.unmodifiableList( this.groups );
-		this.numTimepoints = numTimePoints;
+		state = new SynchronizedViewerState( new BasicViewerState() );
+		state.addSources( sources );
+		state.setSourcesActive( sources, true );
+		sourceGroups.forEach( sourceGroup -> {
+			final bdv.viewer.SourceGroup handle = new bdv.viewer.SourceGroup();
+			state.addGroup( handle );
+			state.setGroupName( handle, sourceGroup.getName() );
+			state.setGroupActive( handle, sourceGroup.isActive() );
+			sourceGroup.getSourceIds().forEach( i -> state.addSourceToGroup( sources.get( i ), handle ) );
+		} );
+		state.setNumTimepoints( numTimePoints );
+		state.setViewerTransform( new AffineTransform3D() );
+		state.setInterpolation( NEARESTNEIGHBOR );
+		state.setDisplayMode( SINGLE );
+		state.setCurrentSource( sources.isEmpty() ? null : sources.get( 0 ) );
+		state.setCurrentGroup( state.getGroups().isEmpty() ? null : state.getGroups().get( 0 ) );
+		state.setCurrentTimepoint( 0 );
+	}
 
-		viewerTransform = new AffineTransform3D();
-		interpolation = NEARESTNEIGHBOR;
-		displayMode = SINGLE;
-		currentSource = sources.isEmpty() ? -1 : 0;
-		currentGroup = groups.isEmpty() ? -1 : 0;
-		currentTimepoint = 0;
+	public ViewerState( final SynchronizedViewerState state )
+	{
+		this.state = state;
 	}
 
 	/**
 	 * copy constructor
+	 *
 	 * @param s
 	 */
 	protected ViewerState( final ViewerState s )
 	{
-		sources = new ArrayList<>( s.sources.size() );
-		for ( final SourceState< ? > source : s.sources )
-			this.sources.add( source.copy( this ) );
-		unmodifiableSources = Collections.unmodifiableList( sources );
-		groups = new ArrayList<>( s.groups.size() );
-		for ( final SourceGroup group : s.groups )
-			groups.add( group.copy() );
-		unmodifiableGroups = Collections.unmodifiableList( groups );
-		numTimepoints = s.numTimepoints;
-		viewerTransform = s.viewerTransform.copy();
-		interpolation = s.interpolation;
-		displayMode = s.displayMode;
-		currentSource = s.currentSource;
-		currentGroup = s.currentGroup;
-		currentTimepoint = s.currentTimepoint;
+		synchronized ( s.state )
+		{
+			state = new SynchronizedViewerState( new BasicViewerState( s.state ) );
+		}
 	}
 
 	public synchronized ViewerState copy()
@@ -180,84 +127,106 @@ public class ViewerState
 	/**
 	 * Get the viewer transform.
 	 *
-	 * @param t is set to the viewer transform.
+	 * @param t
+	 * 		is set to the viewer transform.
 	 */
-	public synchronized void getViewerTransform( final AffineTransform3D t )
+	public void getViewerTransform( final AffineTransform3D t )
 	{
-		t.set( viewerTransform );
+		state.getViewerTransform( t );
 	}
 
 	/**
 	 * Set the viewer transform.
 	 *
-	 * @param t transform parameters.
+	 * @param t
+	 * 		transform parameters.
 	 */
-	public synchronized void setViewerTransform( final AffineTransform3D t )
+	public void setViewerTransform( final AffineTransform3D t )
 	{
-		viewerTransform.set( t );
+		state.setViewerTransform( t );
 	}
 
 	/**
 	 * Get the index of the current source.
 	 */
-	public synchronized int getCurrentSource()
+	public int getCurrentSource()
 	{
-		return currentSource;
+		synchronized ( state )
+		{
+			return state.getSources().indexOf( state.getCurrentSource() );
+		}
 	}
 
 	/**
 	 * Make the source with the given index current.
 	 */
-	public synchronized void setCurrentSource( final int index )
+	public void setCurrentSource( final int index )
 	{
-		final int minIndex = sources.isEmpty() ? -1 : 0;
-		if ( index >= minIndex && index < sources.size() )
+		synchronized ( state )
 		{
-			sources.get( currentSource ).setCurrent( false );
-			currentSource = index;
-			sources.get( currentSource ).setCurrent( true );
+			state.setCurrentSource( state.getSources().get( index ) );
 		}
 	}
 
 	/**
 	 * Make the given source current.
 	 */
-	public synchronized void setCurrentSource( final Source< ? > source )
+	public void setCurrentSource( final Source< ? > source )
 	{
-		final int i = getSourceIndex( source );
-		if ( i >= 0 )
-			setCurrentSource( i );
+		state.setCurrentSource( soc( source ) );
+	}
+
+	private SourceAndConverter< ? > soc( Source< ? > source )
+	{
+		for ( SourceAndConverter< ? > soc : state.getSources() )
+			if ( soc.getSpimSource() == source )
+				return soc;
+		return null;
 	}
 
 	/**
 	 * Get the index of the current group.
 	 */
-	public synchronized int getCurrentGroup()
+	public int getCurrentGroup()
 	{
-		return currentGroup;
+		synchronized ( state )
+		{
+			return state.getGroups().indexOf( state.getCurrentGroup() );
+		}
 	}
 
 	/**
 	 * Make the group with the given index current.
 	 */
-	public synchronized void setCurrentGroup( final int index )
+	public void setCurrentGroup( final int index )
 	{
-		if ( index >= 0 && index < groups.size() )
+		synchronized ( state )
 		{
-			groups.get( currentGroup ).setCurrent( false );
-			currentGroup = index;
-			groups.get( currentGroup ).setCurrent( true );
+			state.setCurrentGroup( state.getGroups().get( index ) );
 		}
 	}
 
 	/**
 	 * Make the given group current.
 	 */
-	public synchronized void setCurrentGroup( final SourceGroup group )
+	public void setCurrentGroup( final SourceGroup group )
 	{
-		final int i = getGroupIndex( group );
-		if ( i >= 0 )
-			setCurrentGroup( i );
+		synchronized ( state )
+		{
+			state.setCurrentGroup( getHandle( group ) );
+		}
+	}
+
+	private bdv.viewer.SourceGroup getHandle( final SourceGroup group )
+	{
+		for ( bdv.viewer.SourceGroup handle : state.getGroups() )
+		{
+			SourceGroup g = new SourceGroup( state.getGroupName( handle ) );
+			state.getSourcesInGroup( handle ).forEach( s -> g.addSource( state.getSources().indexOf( s ) ) );
+			if ( g.equals( group ) )
+				return handle;
+		}
+		return null;
 	}
 
 	/**
@@ -265,19 +234,20 @@ public class ViewerState
 	 *
 	 * @return interpolation method.
 	 */
-	public synchronized Interpolation getInterpolation()
+	public Interpolation getInterpolation()
 	{
-		return interpolation;
+		return state.getInterpolation();
 	}
 
 	/**
 	 * Set the interpolation method.
 	 *
-	 * @param method interpolation method.
+	 * @param method
+	 * 		interpolation method.
 	 */
-	public synchronized void setInterpolation( final Interpolation method )
+	public void setInterpolation( final Interpolation method )
 	{
-		interpolation = method;
+		state.setInterpolation( method );
 	}
 
 	/**
@@ -290,9 +260,9 @@ public class ViewerState
 	 * @deprecated replaced by {@link #getDisplayMode()}
 	 */
 	@Deprecated
-	public synchronized boolean isSingleSourceMode()
+	public boolean isSingleSourceMode()
 	{
-		return displayMode == SINGLE;
+		return state.getDisplayMode() == SINGLE;
 	}
 
 	/**
@@ -301,13 +271,13 @@ public class ViewerState
 	 * angle) is shown. In <em>fused</em> mode, all active sources are blended.
 	 *
 	 * @param singleSourceMode
-	 *            If true, set <em>single-source</em> mode. If false, set
-	 *            <em>fused</em> mode.
+	 * 		If true, set <em>single-source</em> mode. If false, set
+	 * 		<em>fused</em> mode.
 	 *
 	 * @deprecated replaced by {@link #setDisplayMode(DisplayMode)}
 	 */
 	@Deprecated
-	public synchronized void setSingleSourceMode( final boolean singleSourceMode )
+	public void setSingleSourceMode( final boolean singleSourceMode )
 	{
 		if ( singleSourceMode )
 			setDisplayMode( SINGLE );
@@ -326,11 +296,11 @@ public class ViewerState
 	 * </ul>
 	 *
 	 * @param mode
-	 *            the display mode
+	 * 		the display mode
 	 */
-	public synchronized void setDisplayMode( final DisplayMode mode )
+	public void setDisplayMode( final DisplayMode mode )
 	{
-		displayMode = mode;
+		state.setDisplayMode( mode );
 	}
 
 	/**
@@ -345,9 +315,9 @@ public class ViewerState
 	 *
 	 * @return the current display mode
 	 */
-	public synchronized DisplayMode getDisplayMode()
+	public DisplayMode getDisplayMode()
 	{
-		return displayMode;
+		return state.getDisplayMode();
 	}
 
 	/**
@@ -355,20 +325,20 @@ public class ViewerState
 	 *
 	 * @return current timepoint index
 	 */
-	public synchronized int getCurrentTimepoint()
+	public int getCurrentTimepoint()
 	{
-		return currentTimepoint;
+		return state.getCurrentTimepoint();
 	}
 
 	/**
 	 * Set the current timepoint index.
 	 *
 	 * @param timepoint
-	 *            timepoint index.
+	 * 		timepoint index.
 	 */
-	public synchronized void setCurrentTimepoint( final int timepoint )
+	public void setCurrentTimepoint( final int timepoint )
 	{
-		currentTimepoint = timepoint;
+		state.setCurrentTimepoint( timepoint );
 	}
 
 	/**
@@ -378,7 +348,16 @@ public class ViewerState
 	 */
 	public List< SourceState< ? > > getSources()
 	{
-		return unmodifiableSources;
+		synchronized ( state )
+		{
+			List< SourceState< ? > > sourceStates = new ArrayList<>();
+			for ( SourceAndConverter< ? > source : state.getSources() )
+			{
+				final SourceState< ? > ss = new SourceState<>( source, this );
+				sourceStates.add( ss );
+			}
+			return sourceStates;
+		}
 	}
 
 	/**
@@ -388,9 +367,11 @@ public class ViewerState
 	 */
 	public int numSources()
 	{
-		return sources.size();
+		return state.getSources().size();
 	}
 
+	final Map< bdv.viewer.SourceGroup, SourceGroup > handleToSourceGroup = new HashMap<>();
+
 	/**
 	 * Returns a list of all source groups.
 	 *
@@ -398,7 +379,18 @@ public class ViewerState
 	 */
 	public List< SourceGroup > getSourceGroups()
 	{
-		return unmodifiableGroups;
+		synchronized ( state )
+		{
+			List< SourceGroup > sourceGroups = new ArrayList<>();
+			for ( bdv.viewer.SourceGroup handle : state.getGroups() )
+			{
+				sourceGroups.add( handleToSourceGroup.computeIfAbsent( handle, h -> {
+					SourceGroup g = new SourceGroup( state, handle );
+					return g;
+				} ) );
+			}
+			return sourceGroups;
+		}
 	}
 
 	/**
@@ -408,139 +400,83 @@ public class ViewerState
 	 */
 	public int numSourceGroups()
 	{
-		return groups.size();
+		synchronized ( state )
+		{
+			return state.getGroups().size();
+		}
 	}
 
-	public synchronized void addSource( final SourceAndConverter< ? > source )
+	public void addSource( final SourceAndConverter< ? > source )
 	{
-		sources.add( SourceState.create( source, this ) );
-		if ( currentSource < 0 )
-			currentSource = 0;
+		synchronized ( state )
+		{
+			state.addSource( source );
+			state.setSourceActive( source, true );
+		}
 	}
 
-	public synchronized void removeSource( final Source< ? > source )
+	public void removeSource( final Source< ? > source )
 	{
-		for ( int i = 0; i < sources.size(); )
+		synchronized ( state )
 		{
-			final SourceState< ? > s = sources.get( i );
-			if ( s.getSpimSource() == source )
-				removeSource( i );
-			else
-				i++;
+			state.removeSource( soc ( source ) );
 		}
 	}
 
 	protected void removeSource( final int index )
 	{
-		sources.remove( index );
-		if ( sources.isEmpty() )
-			currentSource = -1;
-		else if ( currentSource == index )
-			currentSource = 0;
-		else if ( currentSource > index )
-			--currentSource;
-		for( final SourceGroup group : groups )
+		synchronized ( state )
 		{
-			final SortedSet< Integer > ids = group.getSourceIds();
-			final ArrayList< Integer > oldids = new ArrayList<>( ids );
-			ids.clear();
-			for ( final int id : oldids )
-			{
-				if ( id < index )
-					ids.add( id );
-				else if ( id > index )
-					ids.add( id - 1 );
-			}
+			state.removeSource( state.getSources().get( index ) );
 		}
 	}
 
-	public synchronized void addGroup( final SourceGroup group )
+	public void addGroup( final SourceGroup group )
 	{
-		if ( !groups.contains( group ) )
+		synchronized ( state )
 		{
-			groups.add( group );
-			if ( currentGroup < 0 )
-				currentGroup = 0;
+			final bdv.viewer.SourceGroup handle = new bdv.viewer.SourceGroup();
+			state.addGroup( handle );
+			state.setGroupName( handle, group.getName() );
+			state.setGroupActive( handle, group.isActive() );
+			group.getSourceIds().forEach( i -> state.addSourceToGroup( state.getSources().get( i ), handle ) );
 		}
 	}
 
-	public synchronized void removeGroup( final SourceGroup group )
+	public void removeGroup( final SourceGroup group )
 	{
-		final int i = groups.indexOf( group );
-		if ( i >= 0 )
-			removeGroup( i );
+		synchronized ( state )
+		{
+			state.removeGroup( getHandle( group ) );
+		}
 	}
 
 	protected void removeGroup( final int index )
 	{
-		groups.remove( index );
-		if ( groups.isEmpty() )
-			currentGroup = -1;
-		else if ( currentGroup == index )
-			currentGroup = 0;
-		else if ( currentGroup > index )
-			--currentGroup;
+		state.removeGroup( state.getGroups().get( index ) );
 	}
 
-	public synchronized boolean isSourceVisible( final int index )
+	public boolean isSourceVisible( final int index )
 	{
-		switch ( displayMode )
+		synchronized ( state )
 		{
-		case SINGLE:
-			return ( index == currentSource ) && isPresent( index );
-		case GROUP:
-			return groups.get( currentGroup ).getSourceIds().contains( index ) && isPresent( index );
-		case FUSED:
-			return sources.get( index ).isActive() && isPresent( index );
-		case FUSEDGROUP:
-		default:
-			for ( final SourceGroup group : groups )
-				if ( group.isActive() && group.getSourceIds().contains( index ) && isPresent( index ) )
-					return true;
-			return false;
+			return state.isSourceVisibleAndPresent( state.getSources().get( index ) );
 		}
 	}
 
-	private boolean isPresent( final int sourceId )
-	{
-		return sources.get( sourceId ).getSpimSource().isPresent( currentTimepoint );
-	}
-
 	/**
 	 * Returns a list of the indices of all currently visible sources.
 	 *
 	 * @return indices of all currently visible sources.
 	 */
-	public synchronized List< Integer > getVisibleSourceIndices()
+	public List< Integer > getVisibleSourceIndices()
 	{
-		final ArrayList< Integer > visible = new ArrayList<>();
-		switch ( displayMode )
+		synchronized ( state )
 		{
-		case SINGLE:
-			if ( currentSource >= 0 && isPresent( currentSource ) )
-				visible.add( currentSource );
-			break;
-		case GROUP:
-			for ( final int sourceId : groups.get( currentGroup ).getSourceIds() )
-				if ( isPresent( sourceId ) )
-					visible.add( sourceId );
-			break;
-		case FUSED:
-			for ( int i = 0; i < sources.size(); ++i )
-				if ( sources.get( i ).isActive() && isPresent( i ) )
-					visible.add( i );
-			break;
-		case FUSEDGROUP:
-			final TreeSet< Integer > gactive = new TreeSet<>();
-			for ( final SourceGroup group : groups )
-				if ( group.isActive() )
-					gactive.addAll( group.getSourceIds() );
-			for ( final int sourceId : new ArrayList<>( gactive ) )
-				if ( isPresent( sourceId ) )
-					visible.add( sourceId );
-			break;
+			final List< SourceAndConverter< ? > > sources = new ArrayList<>( state.getVisibleAndPresentSources() );
+			sources.sort( state.sourceOrder() );
+			return sources.stream().map( state.getSources()::indexOf ).collect( Collectors.toList() );
 		}
-		return visible;
 	}
 
 	/*
@@ -551,28 +487,36 @@ public class ViewerState
 	 * Get the mipmap level that best matches the given screen scale for the given source.
 	 *
 	 * @param screenScaleTransform
-	 *            screen scale, transforms screen coordinates to viewer coordinates.
+	 * 		screen scale, transforms screen coordinates to viewer coordinates.
+	 *
 	 * @return mipmap level
 	 */
-	public synchronized int getBestMipMapLevel( final AffineTransform3D screenScaleTransform, final int sourceIndex )
+	public int getBestMipMapLevel( final AffineTransform3D screenScaleTransform, final int sourceIndex )
 	{
-		return getBestMipMapLevel( screenScaleTransform, sources.get( sourceIndex ).getSpimSource() );
+		synchronized ( state )
+		{
+			return getBestMipMapLevel( screenScaleTransform, state.getSources().get( sourceIndex ).getSpimSource() );
+		}
 	}
 
 	/**
 	 * Get the mipmap level that best matches the given screen scale for the given source.
 	 *
 	 * @param screenScaleTransform
-	 *            screen scale, transforms screen coordinates to viewer coordinates.
+	 * 		screen scale, transforms screen coordinates to viewer coordinates.
+	 *
 	 * @return mipmap level
 	 */
-	public synchronized int getBestMipMapLevel( final AffineTransform3D screenScaleTransform, final Source< ? > source )
+	public int getBestMipMapLevel( final AffineTransform3D screenScaleTransform, final Source< ? > source )
 	{
-		final AffineTransform3D screenTransform = new AffineTransform3D();
-		getViewerTransform( screenTransform );
-		screenTransform.preConcatenate( screenScaleTransform );
+		synchronized ( state )
+		{
+			final AffineTransform3D screenTransform = new AffineTransform3D();
+			getViewerTransform( screenTransform );
+			screenTransform.preConcatenate( screenScaleTransform );
 
-		return MipmapTransforms.getBestMipMapLevel( screenTransform, source, currentTimepoint );
+			return MipmapTransforms.getBestMipMapLevel( screenTransform, source, state.getCurrentTimepoint() );
+		}
 	}
 
 	/**
@@ -580,20 +524,20 @@ public class ViewerState
 	 *
 	 * @return the number of timepoints.
 	 */
-	public synchronized int getNumTimepoints()
+	public int getNumTimepoints()
 	{
-		return numTimepoints;
+		return state.getNumTimepoints();
 	}
 
 	/**
 	 * Set the number of timepoints.
 	 *
 	 * @param numTimepoints
-	 *            the number of timepoints.
+	 * 		the number of timepoints.
 	 */
-	public synchronized void setNumTimepoints( final int numTimepoints )
+	public void setNumTimepoints( final int numTimepoints )
 	{
-		this.numTimepoints = numTimepoints;
+		state.setNumTimepoints( numTimepoints );
 	}
 
 	/**
@@ -606,31 +550,7 @@ public class ViewerState
 	 */
 	public void kill()
 	{
-		sources.clear();
-		groups.clear();
-	}
-
-	/**
-	 * Get index of (first) {@link SourceState} that matches the given
-	 * {@link Source} or {@code -1} if not found.
-	 */
-	private int getSourceIndex( final Source< ? > source )
-	{
-		for ( int i = 0; i < sources.size(); ++i )
-		{
-			final SourceState< ? > s = sources.get( i );
-			if ( s.getSpimSource() == source )
-				return i;
-		}
-		return -1;
-	}
-
-	/**
-	 * Get index of (first) {@link SourceGroup} that matches the given
-	 * {@code group} or {@code -1} if not found.
-	 */
-	private int getGroupIndex( final SourceGroup group )
-	{
-		return groups.indexOf( group );
+		state.clearGroups();
+		state.clearSources();
 	}
 }
diff --git a/src/main/java/bdv/viewer/state/XmlIoViewerState.java b/src/main/java/bdv/viewer/state/XmlIoViewerState.java
index 5ec74f5e47bc1531a5f9860bb90c2e511a46ec4c..97331ae6ae6872504e9572998b39621704270a5b 100644
--- a/src/main/java/bdv/viewer/state/XmlIoViewerState.java
+++ b/src/main/java/bdv/viewer/state/XmlIoViewerState.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/net/imglib2/display/ARGBARGBColorConverter.java b/src/main/java/net/imglib2/display/ARGBARGBColorConverter.java
index 0b0998b090f46202c41478575cafcad6582b6b60..cc5a3f23e7d2ac1fc63eddff7eb5e669f241823b 100644
--- a/src/main/java/net/imglib2/display/ARGBARGBColorConverter.java
+++ b/src/main/java/net/imglib2/display/ARGBARGBColorConverter.java
@@ -1,9 +1,8 @@
 /*-
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/java/net/imglib2/display/ColorConverter.java b/src/main/java/net/imglib2/display/ColorConverter.java
index a257883d495435901f7be093c7f399a4304573bd..ffde94ae86b72a7ecd0ed7617d3a9f40e079ddf2 100644
--- a/src/main/java/net/imglib2/display/ColorConverter.java
+++ b/src/main/java/net/imglib2/display/ColorConverter.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -33,9 +32,9 @@ import net.imglib2.type.numeric.ARGBType;
 
 public interface ColorConverter extends LinearRange
 {
-	public ARGBType getColor();
+	ARGBType getColor();
 
-	public void setColor( final ARGBType c );
+	void setColor( final ARGBType c );
 
-	public boolean supportsColor();
+	boolean supportsColor();
 }
diff --git a/src/main/java/net/imglib2/display/RealARGBColorConverter.java b/src/main/java/net/imglib2/display/RealARGBColorConverter.java
index bf4019db065b7f1e47a2082e694a22b4e30248d2..5a66799fc23f3e284c9e66f81d9a33ca69c71a0b 100644
--- a/src/main/java/net/imglib2/display/RealARGBColorConverter.java
+++ b/src/main/java/net/imglib2/display/RealARGBColorConverter.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
@@ -36,7 +35,7 @@ import net.imglib2.type.numeric.RealType;
 
 public interface RealARGBColorConverter< R extends RealType< ? > > extends ColorConverter, Converter< R, ARGBType >
 {
-	public static < R extends RealType< ? > > RealARGBColorConverter< R > create( final R type, final double min, final double max )
+	static < R extends RealType< ? > > RealARGBColorConverter< R > create( final R type, final double min, final double max )
 	{
 		return Instances.create( type, min, max );
 	}
@@ -58,7 +57,7 @@ class Instances
 					provider = new ClassCopyProvider<>( Imp.class, RealARGBColorConverter.class, double.class, double.class );
 			}
 		}
-		return provider.newInstanceForKey( type, min, max );
+		return provider.newInstanceForKey( type.getClass(), min, max );
 	}
 
 	public static class Imp< R extends RealType< ? > > implements RealARGBColorConverter< R >
diff --git a/src/main/java/net/imglib2/display/ScaledARGBConverter.java b/src/main/java/net/imglib2/display/ScaledARGBConverter.java
index 8b8e616574ea018717d521c7297eebe2feb2231d..dd9f50cdeea6b554925b2a99f72aa51d3265ac7d 100644
--- a/src/main/java/net/imglib2/display/ScaledARGBConverter.java
+++ b/src/main/java/net/imglib2/display/ScaledARGBConverter.java
@@ -1,9 +1,8 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies
+ * BigDataViewer core classes with minimal dependencies.
  * %%
- * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
- * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
diff --git a/src/main/resources/bdv/ui/splitpanel/leftdoublearrow_tiny.png b/src/main/resources/bdv/ui/splitpanel/leftdoublearrow_tiny.png
new file mode 100644
index 0000000000000000000000000000000000000000..9059a79198b68dd5ac9553667fe3ac5b6516ca46
Binary files /dev/null and b/src/main/resources/bdv/ui/splitpanel/leftdoublearrow_tiny.png differ
diff --git a/src/main/resources/bdv/ui/splitpanel/rightdoublearrow_tiny.png b/src/main/resources/bdv/ui/splitpanel/rightdoublearrow_tiny.png
new file mode 100644
index 0000000000000000000000000000000000000000..4cf801e7ee181796b6366a782c603594b6c764d1
Binary files /dev/null and b/src/main/resources/bdv/ui/splitpanel/rightdoublearrow_tiny.png differ
diff --git a/src/main/resources/bdv/ui/viewermodepanel/fusion_mode.png b/src/main/resources/bdv/ui/viewermodepanel/fusion_mode.png
new file mode 100644
index 0000000000000000000000000000000000000000..cd2fd7e61e2e776c6b34e540a7720effca5664c9
Binary files /dev/null and b/src/main/resources/bdv/ui/viewermodepanel/fusion_mode.png differ
diff --git a/src/main/resources/bdv/ui/viewermodepanel/fusion_mode.svg b/src/main/resources/bdv/ui/viewermodepanel/fusion_mode.svg
new file mode 100644
index 0000000000000000000000000000000000000000..a246bd94e7ec4d5442321af9ed8f8f1d005a28bd
--- /dev/null
+++ b/src/main/resources/bdv/ui/viewermodepanel/fusion_mode.svg
@@ -0,0 +1,115 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!--
+  #%L
+  BigDataViewer core classes with minimal dependencies.
+  %%
+  Copyright (C) 2012 - 2020 BigDataViewer developers.
+  %%
+  Redistribution and use in source and binary forms, with or without
+  modification, are permitted provided that the following conditions are met:
+  
+  1. Redistributions of source code must retain the above copyright notice,
+     this list of conditions and the following disclaimer.
+  2. Redistributions in binary form must reproduce the above copyright notice,
+     this list of conditions and the following disclaimer in the documentation
+     and/or other materials provided with the distribution.
+  
+  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+  POSSIBILITY OF SUCH DAMAGE.
+  #L%
+  -->
+
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="40"
+   height="39.995888"
+   viewBox="0 0 10.583333 10.582246"
+   version="1.1"
+   id="svg8"
+   inkscape:version="0.92.3 (2405546, 2018-03-11)"
+   sodipodi:docname="fusion_mode.svg"
+   inkscape:export-filename="/home/random/Development/imagej/bigdataviewer/bigdataviewer-core/src/main/resources/bdv/ui/viewermodepanel/fusion_mode.png"
+   inkscape:export-xdpi="96"
+   inkscape:export-ydpi="96">
+  <defs
+     id="defs2" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#ffffff"
+     bordercolor="#4a4a4a"
+     borderopacity="1"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="11.313709"
+     inkscape:cx="28.264648"
+     inkscape:cy="15.375783"
+     inkscape:document-units="mm"
+     inkscape:current-layer="g824"
+     showgrid="false"
+     units="px"
+     inkscape:pagecheckerboard="true"
+     inkscape:window-width="1920"
+     inkscape:window-height="1023"
+     inkscape:window-x="0"
+     inkscape:window-y="33"
+     inkscape:window-maximized="1"
+     fit-margin-top="0"
+     fit-margin-left="0"
+     fit-margin-right="0"
+     fit-margin-bottom="0" />
+  <metadata
+     id="metadata5">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(-2.4093051e-8,-282.97707)">
+    <g
+       id="g824"
+       transform="translate(0,-3.4406551)">
+      <path
+         style="opacity:1;fill:#2e2e2e;fill-opacity:0.4545455;fill-rule:nonzero;stroke:#000000;stroke-width:0.5333333;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+         d="M 13.224609 5.5175781 C 12.198936 5.5175781 11.373047 6.3435658 11.373047 7.3691406 L 11.373047 11.087891 L 19.203125 11.087891 C 28.329593 11.087891 28.028457 11.065854 28.552734 11.8125 L 28.830078 12.208984 L 28.861328 20.791016 L 28.890625 28.623047 L 32.628906 28.623047 C 33.65458 28.623047 34.480469 27.797097 34.480469 26.771484 L 34.480469 7.3691406 C 34.480469 6.3435658 33.65458 5.5175781 32.628906 5.5175781 L 13.224609 5.5175781 z "
+         transform="matrix(0.26458333,0,0,0.26458333,2.4093051e-8,286.41773)"
+         id="rect815-3" />
+      <path
+         style="opacity:1;fill:#2e2e2e;fill-opacity:0.4545455;fill-rule:nonzero;stroke:#000000;stroke-width:0.5333333;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+         d="M 7.3710938 11.373047 C 6.3454271 11.373047 5.5195312 12.199035 5.5195312 13.224609 L 5.5195312 32.626953 C 5.5195312 33.652528 6.3454271 34.476562 7.3710938 34.476562 L 26.775391 34.476562 C 27.801057 34.476562 28.625 33.652528 28.625 32.626953 L 28.625 28.894531 L 20.814453 28.890625 C 13.22531 28.888168 12.225401 28.868164 11.884766 28.699219 C 11.673808 28.595282 11.3961 28.338191 11.265625 28.126953 C 11.033853 27.752024 11.027344 27.545226 11.027344 19.154297 L 11.027344 11.373047 L 7.3710938 11.373047 z "
+         id="rect815"
+         transform="matrix(0.26458333,0,0,0.26458333,2.4093051e-8,286.41773)" />
+      <path
+         style="opacity:1;fill:#878787;fill-opacity:0.49416342;stroke:none;stroke-width:4.15748024;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:0"
+         d="m 13.048835,28.5158 c -0.859221,-0.0424 -1.06224,-0.08004 -1.227539,-0.227579 -0.462376,-0.412699 -0.449152,-0.180172 -0.50409,-8.863998 l -0.05115,-8.084579 7.946868,0.04983 c 4.605136,0.02888 8.114124,0.08595 8.344615,0.135737 0.289611,0.06255 0.475847,0.176181 0.68501,0.417944 l 0.287262,0.332036 2.65e-4,6.371332 c 8.9e-5,3.504232 0.02633,7.17677 0.05822,8.161196 l 0.05799,1.789864 -7.283971,-0.01549 c -4.006184,-0.0085 -7.747239,-0.03835 -8.313455,-0.06629 z"
+         id="path4776"
+         inkscape:connector-curvature="0"
+         transform="matrix(0.26458333,0,0,0.26458333,2.4093051e-8,286.41773)" />
+    </g>
+  </g>
+</svg>
diff --git a/src/main/resources/bdv/ui/viewermodepanel/grouping_mode.png b/src/main/resources/bdv/ui/viewermodepanel/grouping_mode.png
new file mode 100644
index 0000000000000000000000000000000000000000..69aebc0bdf841d0404c6b976aeb436978373c4ec
Binary files /dev/null and b/src/main/resources/bdv/ui/viewermodepanel/grouping_mode.png differ
diff --git a/src/main/resources/bdv/ui/viewermodepanel/grouping_mode.svg b/src/main/resources/bdv/ui/viewermodepanel/grouping_mode.svg
new file mode 100644
index 0000000000000000000000000000000000000000..70fa601515b9f610c6e3a5161e60080c4a4b6da8
--- /dev/null
+++ b/src/main/resources/bdv/ui/viewermodepanel/grouping_mode.svg
@@ -0,0 +1,146 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!--
+  #%L
+  BigDataViewer core classes with minimal dependencies.
+  %%
+  Copyright (C) 2012 - 2020 BigDataViewer developers.
+  %%
+  Redistribution and use in source and binary forms, with or without
+  modification, are permitted provided that the following conditions are met:
+  
+  1. Redistributions of source code must retain the above copyright notice,
+     this list of conditions and the following disclaimer.
+  2. Redistributions in binary form must reproduce the above copyright notice,
+     this list of conditions and the following disclaimer in the documentation
+     and/or other materials provided with the distribution.
+  
+  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+  POSSIBILITY OF SUCH DAMAGE.
+  #L%
+  -->
+
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="40"
+   height="40"
+   viewBox="0 0 10.583333 10.583334"
+   version="1.1"
+   id="svg8"
+   inkscape:version="0.92.3 (2405546, 2018-03-11)"
+   sodipodi:docname="grouping_mode.svg"
+   inkscape:export-filename="/home/random/Development/imagej/bigdataviewer/bigdataviewer-core/src/main/resources/bdv/ui/viewermodepanel/grouping_mode.png"
+   inkscape:export-xdpi="96"
+   inkscape:export-ydpi="96">
+  <defs
+     id="defs2" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#ffffff"
+     bordercolor="#4a4a4a"
+     borderopacity="1"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="7.1200577"
+     inkscape:cx="-10.128712"
+     inkscape:cy="23.002358"
+     inkscape:document-units="mm"
+     inkscape:current-layer="g835"
+     showgrid="false"
+     units="px"
+     inkscape:pagecheckerboard="true"
+     inkscape:window-width="1920"
+     inkscape:window-height="1023"
+     inkscape:window-x="0"
+     inkscape:window-y="33"
+     inkscape:window-maximized="1"
+     fit-margin-top="0"
+     fit-margin-left="0"
+     fit-margin-right="0"
+     fit-margin-bottom="0" />
+  <metadata
+     id="metadata5">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(-5.555898e-5,-282.97696)">
+    <g
+       id="g835"
+       transform="translate(0,-3.4396431)">
+      <path
+         style="opacity:1;fill:#2e2e2e;fill-opacity:0.22568093;fill-rule:nonzero;stroke:#000000;stroke-width:0.5333333;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+         d="M 23.878906 3.2832031 C 23.512957 3.2373067 23.139925 3.4095307 22.943359 3.75 L 20.339844 8.2578125 L 5.0351562 8.2578125 C 4.4648104 8.2578125 4.0058594 8.7168165 4.0058594 9.2871094 L 4.0058594 24.736328 C 4.0058594 25.306621 4.4648104 25.767578 5.0351562 25.767578 L 14.421875 25.767578 L 17.205078 36.154297 C 17.351909 36.702253 17.910947 37.025552 18.458984 36.878906 L 33.304688 32.900391 C 33.852726 32.753556 34.176128 32.194478 34.029297 31.646484 L 31.300781 21.462891 L 36.880859 11.796875 C 37.142946 11.342916 36.987196 10.765828 36.533203 10.503906 L 24.236328 3.4023438 C 24.12283 3.3367973 24.000889 3.2985019 23.878906 3.2832031 z "
+         transform="matrix(0.26458333,0,0,0.26458333,5.555898e-5,286.4166)"
+         id="rect4760" />
+      <g
+         style="stroke-width:1.93506229;opacity:0.217"
+         transform="matrix(0.51677959,0,0,0.51677889,1.0233023,139.78716)"
+         id="g1509">
+        <rect
+           style="opacity:1;fill:#2e2e2e;fill-opacity:0.09338521;fill-rule:nonzero;stroke:#000000;stroke-width:0.27305877;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+           id="rect815"
+           width="8.9647007"
+           height="8.963871"
+           x="0.070555583"
+           y="287.96555"
+           rx="0.52709115"
+           ry="0.52704239" />
+      </g>
+      <g
+         style="stroke-width:1.94521153;opacity:0.217"
+         transform="matrix(0.49656638,-0.13305461,0.13305429,0.49656554,-35.4577,149.73876)"
+         id="g1505">
+        <rect
+           style="opacity:1;fill:#2e2e2e;fill-opacity:0.09338521;fill-rule:nonzero;stroke:#000000;stroke-width:0.27449092;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+           id="rect815-3"
+           width="8.9647007"
+           height="8.963871"
+           x="1.5480773"
+           y="286.48828"
+           rx="0.52709115"
+           ry="0.52704239" />
+      </g>
+      <g
+         style="stroke-width:2.10530305;opacity:0.217"
+         transform="matrix(0.41135457,0.23749567,-0.23749535,0.41135401,74.556992,168.71925)"
+         id="g1509-5">
+        <rect
+           style="opacity:1;fill:#2e2e2e;fill-opacity:0.09338523;fill-rule:nonzero;stroke:#000000;stroke-width:0.29708165;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+           id="rect815-35"
+           width="8.9647007"
+           height="8.963871"
+           x="0.070555463"
+           y="287.96555"
+           rx="0.52709115"
+           ry="0.52704239" />
+      </g>
+    </g>
+  </g>
+</svg>
diff --git a/src/main/resources/bdv/ui/viewermodepanel/linear.png b/src/main/resources/bdv/ui/viewermodepanel/linear.png
new file mode 100644
index 0000000000000000000000000000000000000000..05c16670bb2394bfb6c8690ad8b1f3f2569e3683
Binary files /dev/null and b/src/main/resources/bdv/ui/viewermodepanel/linear.png differ
diff --git a/src/main/resources/bdv/ui/viewermodepanel/linear.svg b/src/main/resources/bdv/ui/viewermodepanel/linear.svg
new file mode 100644
index 0000000000000000000000000000000000000000..a4a64dcc28a820e41dea7e68ec8d5a018029f70e
--- /dev/null
+++ b/src/main/resources/bdv/ui/viewermodepanel/linear.svg
@@ -0,0 +1,128 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!--
+  #%L
+  BigDataViewer core classes with minimal dependencies.
+  %%
+  Copyright (C) 2012 - 2020 BigDataViewer developers.
+  %%
+  Redistribution and use in source and binary forms, with or without
+  modification, are permitted provided that the following conditions are met:
+  
+  1. Redistributions of source code must retain the above copyright notice,
+     this list of conditions and the following disclaimer.
+  2. Redistributions in binary form must reproduce the above copyright notice,
+     this list of conditions and the following disclaimer in the documentation
+     and/or other materials provided with the distribution.
+  
+  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+  POSSIBILITY OF SUCH DAMAGE.
+  #L%
+  -->
+
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:xlink="http://www.w3.org/1999/xlink"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="40"
+   height="40"
+   viewBox="0 0 10.583333 10.583334"
+   version="1.1"
+   id="svg8"
+   inkscape:version="0.92.3 (2405546, 2018-03-11)"
+   sodipodi:docname="linear.svg"
+   inkscape:export-filename="/home/random/Development/imagej/bigdataviewer/bigdataviewer-core/src/main/resources/bdv/ui/viewermodepanel/linear.png"
+   inkscape:export-xdpi="96"
+   inkscape:export-ydpi="96">
+  <defs
+     id="defs2">
+    <linearGradient
+       inkscape:collect="always"
+       id="linearGradient891">
+      <stop
+         style="stop-color:#5a5a5a;stop-opacity:1"
+         offset="0"
+         id="stop887" />
+      <stop
+         style="stop-color:#5a5a5a;stop-opacity:0.0233463"
+         offset="1"
+         id="stop889" />
+    </linearGradient>
+    <linearGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient891"
+       id="linearGradient893"
+       x1="4.1781349"
+       y1="286.73645"
+       x2="2.4347966"
+       y2="284.99265"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="matrix(0.82135529,0,0,0.82135529,2.0319136,52.584295)" />
+  </defs>
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="6.2181202"
+     inkscape:cx="3.4996214"
+     inkscape:cy="-12.93145"
+     inkscape:document-units="mm"
+     inkscape:current-layer="layer1"
+     showgrid="false"
+     units="px"
+     inkscape:pagecheckerboard="true"
+     inkscape:window-width="1920"
+     inkscape:window-height="1023"
+     inkscape:window-x="0"
+     inkscape:window-y="33"
+     inkscape:window-maximized="1"
+     fit-margin-top="0"
+     fit-margin-left="0"
+     fit-margin-right="0"
+     fit-margin-bottom="0" />
+  <metadata
+     id="metadata5">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title />
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(-6.9485441e-7,-282.97708)">
+    <rect
+       style="opacity:1;fill:url(#linearGradient893);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.34395838;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+       id="rect885"
+       width="6.0729942"
+       height="6.0729942"
+       x="2.2551703"
+       y="285.23227"
+       rx="0.48999995"
+       ry="0.48999995" />
+  </g>
+</svg>
diff --git a/src/main/resources/bdv/ui/viewermodepanel/nearest.png b/src/main/resources/bdv/ui/viewermodepanel/nearest.png
new file mode 100644
index 0000000000000000000000000000000000000000..40b4806a4adf1f8204adc9f8c16e0be8d700a691
Binary files /dev/null and b/src/main/resources/bdv/ui/viewermodepanel/nearest.png differ
diff --git a/src/main/resources/bdv/ui/viewermodepanel/nearest.svg b/src/main/resources/bdv/ui/viewermodepanel/nearest.svg
new file mode 100644
index 0000000000000000000000000000000000000000..456b4d112d667ff63424bb7a090b2b3cc945f62a
--- /dev/null
+++ b/src/main/resources/bdv/ui/viewermodepanel/nearest.svg
@@ -0,0 +1,100 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!--
+  #%L
+  BigDataViewer core classes with minimal dependencies.
+  %%
+  Copyright (C) 2012 - 2020 BigDataViewer developers.
+  %%
+  Redistribution and use in source and binary forms, with or without
+  modification, are permitted provided that the following conditions are met:
+  
+  1. Redistributions of source code must retain the above copyright notice,
+     this list of conditions and the following disclaimer.
+  2. Redistributions in binary form must reproduce the above copyright notice,
+     this list of conditions and the following disclaimer in the documentation
+     and/or other materials provided with the distribution.
+  
+  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+  POSSIBILITY OF SUCH DAMAGE.
+  #L%
+  -->
+
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="40"
+   height="40"
+   viewBox="0 0 10.583333 10.583334"
+   version="1.1"
+   id="svg8"
+   inkscape:version="0.92.3 (2405546, 2018-03-11)"
+   sodipodi:docname="nearest.svg"
+   inkscape:export-filename="/home/random/Development/imagej/bigdataviewer/bigdataviewer-core/src/main/resources/bdv/ui/viewermodepanel/nearest.png"
+   inkscape:export-xdpi="96"
+   inkscape:export-ydpi="96">
+  <defs
+     id="defs2" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="8.40625"
+     inkscape:cx="-20.858952"
+     inkscape:cy="14.91223"
+     inkscape:document-units="mm"
+     inkscape:current-layer="layer1"
+     showgrid="false"
+     units="px"
+     inkscape:pagecheckerboard="true"
+     inkscape:window-width="1920"
+     inkscape:window-height="1023"
+     inkscape:window-x="0"
+     inkscape:window-y="33"
+     inkscape:window-maximized="1"
+     fit-margin-top="0"
+     fit-margin-left="0"
+     fit-margin-right="0"
+     fit-margin-bottom="0" />
+  <metadata
+     id="metadata5">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title />
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(-1.0335785e-4,-282.97696)">
+    <path
+       style="opacity:1;fill:#5a5a5a;fill-opacity:1;stroke:none;stroke-width:1.10000002;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:0"
+       d="m 6.7112716,285.2712 -0.00273,0.66224 -0.00312,0.75149 -0.7514942,0.003 -0.7514942,0.003 v 0.74798 0.74838 H 4.4478234 3.6932109 v 0.75461 0.75462 H 2.9385991 2.2931247 v 1.1974 c 0,0.17916 0.125953,0.32775 0.2946731,0.36211 h 2.6395846 2.7635342 c 0.1363515,-0.0278 0.2432168,-0.13067 0.2798615,-0.26349 v -2.77367 -2.67701 c -0.043122,-0.1563 -0.1843167,-0.27089 -0.3546991,-0.27089 z"
+       id="path4774"
+       inkscape:connector-curvature="0" />
+  </g>
+</svg>
diff --git a/src/main/resources/bdv/ui/viewermodepanel/single_mode.png b/src/main/resources/bdv/ui/viewermodepanel/single_mode.png
new file mode 100644
index 0000000000000000000000000000000000000000..5004a50d23df51705b4a325befae3702b8752de2
Binary files /dev/null and b/src/main/resources/bdv/ui/viewermodepanel/single_mode.png differ
diff --git a/src/main/resources/bdv/ui/viewermodepanel/single_mode.svg b/src/main/resources/bdv/ui/viewermodepanel/single_mode.svg
new file mode 100644
index 0000000000000000000000000000000000000000..ee61526d19f7910d8404df549c14d44c37a3b466
--- /dev/null
+++ b/src/main/resources/bdv/ui/viewermodepanel/single_mode.svg
@@ -0,0 +1,122 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!--
+  #%L
+  BigDataViewer core classes with minimal dependencies.
+  %%
+  Copyright (C) 2012 - 2020 BigDataViewer developers.
+  %%
+  Redistribution and use in source and binary forms, with or without
+  modification, are permitted provided that the following conditions are met:
+  
+  1. Redistributions of source code must retain the above copyright notice,
+     this list of conditions and the following disclaimer.
+  2. Redistributions in binary form must reproduce the above copyright notice,
+     this list of conditions and the following disclaimer in the documentation
+     and/or other materials provided with the distribution.
+  
+  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+  POSSIBILITY OF SUCH DAMAGE.
+  #L%
+  -->
+
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="40"
+   height="40"
+   viewBox="0 0 10.583333 10.583336"
+   version="1.1"
+   id="svg8"
+   inkscape:version="0.92.3 (2405546, 2018-03-11)"
+   sodipodi:docname="single_mode.svg"
+   inkscape:export-filename="/home/random/Development/imagej/bigdataviewer/bigdataviewer-core/src/main/resources/bdv/ui/viewermodepanel/single_mode.png"
+   inkscape:export-xdpi="96"
+   inkscape:export-ydpi="96">
+  <defs
+     id="defs2" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#ffffff"
+     bordercolor="#4a4a4a"
+     borderopacity="1"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="11.607393"
+     inkscape:cx="13.046863"
+     inkscape:cy="10.536255"
+     inkscape:document-units="mm"
+     inkscape:current-layer="g1509"
+     showgrid="false"
+     units="px"
+     inkscape:pagecheckerboard="true"
+     inkscape:window-width="1716"
+     inkscape:window-height="1080"
+     inkscape:window-x="0"
+     inkscape:window-y="33"
+     inkscape:window-maximized="0"
+     fit-margin-top="0"
+     fit-margin-left="0"
+     fit-margin-right="0"
+     fit-margin-bottom="0" />
+  <metadata
+     id="metadata5">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(-2.7536975e-5,-282.97592)">
+    <g
+       id="g823"
+       transform="translate(2.7512882e-5,-3.4407212)">
+      <rect
+         ry="0.48980322"
+         rx="0.48984864"
+         y="287.81021"
+         x="2.9690354"
+         height="6.2210817"
+         width="6.221662"
+         id="rect815-3"
+         style="opacity:1;fill:#2e2e2e;fill-opacity:0;fill-rule:nonzero;stroke:#000000;stroke-width:0.141;stroke-miterlimit:4;stroke-dasharray:0.564, 0.564;stroke-dashoffset:0;stroke-opacity:1" />
+      <g
+         style="stroke-width:1.07603621"
+         transform="matrix(0.92933683,0,0,0.92933672,0.00498568,20.982005)"
+         id="g1509">
+        <rect
+           style="opacity:1;fill:#2e2e2e;fill-opacity:0.4545455;fill-rule:nonzero;stroke:#000000;stroke-width:0.15184066;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+           id="rect815"
+           width="6.6946864"
+           height="6.6940637"
+           x="1.4931475"
+           y="288.8129"
+           rx="0.52725768"
+           ry="0.52704239" />
+      </g>
+    </g>
+  </g>
+</svg>
diff --git a/src/main/resources/bdv/ui/viewermodepanel/source_mode.png b/src/main/resources/bdv/ui/viewermodepanel/source_mode.png
new file mode 100644
index 0000000000000000000000000000000000000000..0c4bc54bd33ae20c984aa0e7a3b3bf2d2463143d
Binary files /dev/null and b/src/main/resources/bdv/ui/viewermodepanel/source_mode.png differ
diff --git a/src/main/resources/bdv/ui/viewermodepanel/source_mode.svg b/src/main/resources/bdv/ui/viewermodepanel/source_mode.svg
new file mode 100644
index 0000000000000000000000000000000000000000..8aebb3008e2a96ffaa036f0fed3a09fae5ae14cd
--- /dev/null
+++ b/src/main/resources/bdv/ui/viewermodepanel/source_mode.svg
@@ -0,0 +1,133 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!--
+  #%L
+  BigDataViewer core classes with minimal dependencies.
+  %%
+  Copyright (C) 2012 - 2020 BigDataViewer developers.
+  %%
+  Redistribution and use in source and binary forms, with or without
+  modification, are permitted provided that the following conditions are met:
+  
+  1. Redistributions of source code must retain the above copyright notice,
+     this list of conditions and the following disclaimer.
+  2. Redistributions in binary form must reproduce the above copyright notice,
+     this list of conditions and the following disclaimer in the documentation
+     and/or other materials provided with the distribution.
+  
+  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+  POSSIBILITY OF SUCH DAMAGE.
+  #L%
+  -->
+
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="40"
+   height="40"
+   viewBox="0 0 10.583333 10.583334"
+   version="1.1"
+   id="svg8"
+   inkscape:version="0.92.3 (2405546, 2018-03-11)"
+   sodipodi:docname="source_mode.svg"
+   inkscape:export-filename="/home/random/Development/imagej/bigdataviewer/bigdataviewer-core/src/main/resources/bdv/ui/viewermodepanel/source_mode.png"
+   inkscape:export-xdpi="96"
+   inkscape:export-ydpi="96">
+  <defs
+     id="defs2" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#ffffff"
+     bordercolor="#4a4a4a"
+     borderopacity="1"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="9.0909466"
+     inkscape:cx="11.163088"
+     inkscape:cy="13.068008"
+     inkscape:document-units="mm"
+     inkscape:current-layer="g824"
+     showgrid="false"
+     units="px"
+     inkscape:pagecheckerboard="true"
+     inkscape:window-width="1920"
+     inkscape:window-height="1023"
+     inkscape:window-x="0"
+     inkscape:window-y="33"
+     inkscape:window-maximized="1"
+     fit-margin-top="0"
+     fit-margin-left="0"
+     fit-margin-right="0"
+     fit-margin-bottom="0" />
+  <metadata
+     id="metadata5">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(1.9123157e-7,-282.97707)">
+    <g
+       id="g824"
+       transform="translate(-1.9123157e-7,-3.4395876)">
+      <rect
+         ry="0.27236438"
+         rx="0.27238995"
+         y="288.60168"
+         x="1.059764"
+         height="4.6323395"
+         width="4.6327744"
+         id="rect815"
+         style="opacity:1;fill:#2e2e2e;fill-opacity:0.19844358;fill-rule:nonzero;stroke:#000000;stroke-width:0.141;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
+      <rect
+         transform="rotate(30)"
+         ry="0.25034022"
+         rx="0.25036374"
+         y="245.61761"
+         x="148.9614"
+         height="4.2577553"
+         width="4.2581553"
+         id="rect815-35"
+         style="opacity:1;fill:#484848;fill-opacity:0.45136186;fill-rule:nonzero;stroke:#000000;stroke-width:0.141;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
+      <rect
+         ry="0.27094325"
+         rx="0.27096879"
+         y="282.73801"
+         x="-72.2089"
+         height="4.6081691"
+         width="4.6086035"
+         id="rect815-3"
+         style="opacity:1;fill:none;fill-opacity:0.10894943;fill-rule:nonzero;stroke:#000000;stroke-width:0.14111109;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+         transform="rotate(-15.000005)" />
+      <path
+         inkscape:connector-curvature="0"
+         id="rect4773"
+         d="m 6.3179602,287.28534 c -0.096824,-0.0121 -0.1955218,0.0334 -0.2475301,0.12351 l -0.6888467,1.19269 H 1.3322181 c -0.150904,0 -0.2723347,0.12144 -0.2723347,0.27233 v 4.08761 c 0,0.15089 0.1214307,0.27233 0.2723347,0.27233 h 2.4835694 l 0.7363891,2.74867 c 0.038849,0.14499 0.1867611,0.23058 0.3317626,0.19172 l 3.9279258,-1.05264 c 0.1450015,-0.0389 0.2305688,-0.18678 0.1917195,-0.33177 l -0.7219199,-2.69492 1.4763956,-2.55695 c 0.069344,-0.12011 0.028652,-0.27275 -0.091467,-0.3421 l -3.2540649,-1.87896 c -0.03003,-0.0173 -0.062293,-0.0275 -0.094568,-0.0315 z"
+         style="opacity:1;fill:#2e2e2e;fill-opacity:0.1984436;fill-rule:nonzero;stroke:none;stroke-width:0.14111109;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
+    </g>
+  </g>
+</svg>
diff --git a/src/main/resources/openconnectome-bock11-neariso.xml b/src/main/resources/openconnectome-bock11-neariso.xml
index a57d05c3124212397e336e0143c5c6d7ba80bb86..170f1f9fcd6df13afcbcc5059822ab53bf680fb4 100644
--- a/src/main/resources/openconnectome-bock11-neariso.xml
+++ b/src/main/resources/openconnectome-bock11-neariso.xml
@@ -1,35 +1,64 @@
-<?xml version="1.0" encoding="UTF-8"?><SequenceDescription>
-    <BasePath type="relative">.</BasePath>
-    <ImageLoader class="bdv.img.openconnectome.OpenConnectomeImageLoader">
-        <baseUrl>http://openconnecto.me/ocp/ca</baseUrl>
-        <token>bock11</token>
-        <mode>neariso</mode>
-    </ImageLoader>
-    <ViewSetup>
-        <id>0</id>
-        <angle>0</angle>
-        <illumination>0</illumination>
-        <channel>0</channel>
-        <width>0</width>
-        <height>0</height>
-        <depth>0</depth>
-        <pixelWidth>1.0</pixelWidth>
-        <pixelHeight>1.0</pixelHeight>
-        <pixelDepth>1.0</pixelDepth>
-    </ViewSetup>
-    <Timepoints type="range">
-        <first>0</first>
-        <last>0</last>
-    </Timepoints>
-    <ViewRegistrations>
-        <ReferenceTimepoint>0</ReferenceTimepoint>
-        <ViewRegistration>
-            <timepoint>0</timepoint>
-            <setup>0</setup>
-            <affine>1.0 0.0 0.0 0.0
-                    0.0 1.0 0.0 0.0
-                    0.0 0.0 1.0 0.0</affine>
-        </ViewRegistration>
-    </ViewRegistrations>
-</SequenceDescription>
-
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  #%L
+  BigDataViewer core classes with minimal dependencies.
+  %%
+  Copyright (C) 2012 - 2020 BigDataViewer developers.
+  %%
+  Redistribution and use in source and binary forms, with or without
+  modification, are permitted provided that the following conditions are met:
+  
+  1. Redistributions of source code must retain the above copyright notice,
+     this list of conditions and the following disclaimer.
+  2. Redistributions in binary form must reproduce the above copyright notice,
+     this list of conditions and the following disclaimer in the documentation
+     and/or other materials provided with the distribution.
+  
+  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+  POSSIBILITY OF SUCH DAMAGE.
+  #L%
+  -->
+<SequenceDescription>
+    <BasePath type="relative">.</BasePath>
+    <ImageLoader class="bdv.img.openconnectome.OpenConnectomeImageLoader">
+        <baseUrl>http://openconnecto.me/ocp/ca</baseUrl>
+        <token>bock11</token>
+        <mode>neariso</mode>
+    </ImageLoader>
+    <ViewSetup>
+        <id>0</id>
+        <angle>0</angle>
+        <illumination>0</illumination>
+        <channel>0</channel>
+        <width>0</width>
+        <height>0</height>
+        <depth>0</depth>
+        <pixelWidth>1.0</pixelWidth>
+        <pixelHeight>1.0</pixelHeight>
+        <pixelDepth>1.0</pixelDepth>
+    </ViewSetup>
+    <Timepoints type="range">
+        <first>0</first>
+        <last>0</last>
+    </Timepoints>
+    <ViewRegistrations>
+        <ReferenceTimepoint>0</ReferenceTimepoint>
+        <ViewRegistration>
+            <timepoint>0</timepoint>
+            <setup>0</setup>
+            <affine>1.0 0.0 0.0 0.0
+                    0.0 1.0 0.0 0.0
+                    0.0 0.0 1.0 0.0</affine>
+        </ViewRegistration>
+    </ViewRegistrations>
+</SequenceDescription>
+
diff --git a/src/main/resources/viewer/Help.html b/src/main/resources/viewer/Help.html
index d75aa2229742547f5ced915250e40a80408501f1..dcd9325fce0bdd09b3092e4ee3e72b3b01d66b9c 100644
--- a/src/main/resources/viewer/Help.html
+++ b/src/main/resources/viewer/Help.html
@@ -1,3 +1,31 @@
+<!--
+  #%L
+  BigDataViewer core classes with minimal dependencies.
+  %%
+  Copyright (C) 2012 - 2020 BigDataViewer developers.
+  %%
+  Redistribution and use in source and binary forms, with or without
+  modification, are permitted provided that the following conditions are met:
+  
+  1. Redistributions of source code must retain the above copyright notice,
+     this list of conditions and the following disclaimer.
+  2. Redistributions in binary form must reproduce the above copyright notice,
+     this list of conditions and the following disclaimer in the documentation
+     and/or other materials provided with the distribution.
+  
+  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+  POSSIBILITY OF SUCH DAMAGE.
+  #L%
+  -->
 <!-- vim::set expandtab tabstop=4 softtabstop=2 shiftwidth=2: -->
 <html>
 <style TYPE="text/css">
diff --git a/src/test/java/bdv/IntervalPaintingExample.java b/src/test/java/bdv/IntervalPaintingExample.java
new file mode 100644
index 0000000000000000000000000000000000000000..78d234bbe476e3bf50bba288a8cd8045ccd4306e
--- /dev/null
+++ b/src/test/java/bdv/IntervalPaintingExample.java
@@ -0,0 +1,146 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv;
+
+import bdv.export.ProgressWriterConsole;
+import bdv.viewer.ViewerFrame;
+import bdv.viewer.ViewerOptions;
+import bdv.viewer.ViewerPanel;
+import java.awt.Color;
+import java.awt.Graphics;
+import java.awt.Graphics2D;
+import java.io.File;
+import mpicbg.spim.data.SpimDataException;
+import net.imglib2.Interval;
+import bdv.viewer.OverlayRenderer;
+import net.imglib2.util.Intervals;
+import org.scijava.ui.behaviour.DragBehaviour;
+import org.scijava.ui.behaviour.io.InputTriggerConfig;
+import org.scijava.ui.behaviour.util.Behaviours;
+
+public class IntervalPaintingExample
+{
+	public static void main( final String[] args ) throws SpimDataException
+	{
+		final String fn = "/Users/pietzsch/workspace/data/111010_weber_full.xml";
+		System.setProperty( "apple.laf.useScreenMenuBar", "true" );
+		final BigDataViewer bdv = BigDataViewer.open(
+				fn,
+				new File( fn ).getName(),
+				new ProgressWriterConsole(),
+				ViewerOptions.options() );
+		new IntervalPaintingExample( bdv.getViewerFrame() );
+	}
+
+	public IntervalPaintingExample( final ViewerFrame viewerFrame )
+	{
+		this.viewer = viewerFrame.getViewerPanel();
+		viewer.getDisplay().overlays().add( new Overlay() );
+
+		final Behaviours behaviours = new Behaviours( new InputTriggerConfig(), "bdv" );
+		behaviours.behaviour( new RepaintIntervalBehaviour(), "repaint interval", "SPACE" );
+		behaviours.install( viewerFrame.getTriggerbindings(), "repaint" );
+	}
+
+	private final ViewerPanel viewer;
+
+	private Interval repaintInterval;
+
+	private final int halfw = 30;
+
+	private final int halfh = 30;
+
+	private synchronized void repaint( final int x, final int y )
+	{
+		if ( x < 0 || y < 0 )
+			repaintInterval = null;
+		else
+			repaintInterval = Intervals.createMinMax( x - halfw, y - halfh, x + halfw, y + halfh );
+
+		if ( repaintInterval != null )
+			viewer.requestRepaint( repaintInterval );
+
+		viewer.getDisplay().repaint();
+	}
+
+	private synchronized void drawOverlay( final Graphics2D g )
+	{
+		if ( repaintInterval != null )
+		{
+			g.setColor( Color.green );
+			draw( g, Intervals.expand( repaintInterval, 10 ) );
+			g.setColor( Color.red );
+			draw( g, Intervals.expand( repaintInterval, -10 ) );
+		}
+	}
+
+	private static void draw( final Graphics2D g, final Interval interval )
+	{
+		final int x = ( int ) interval.min( 0 );
+		final int y = ( int ) interval.min( 1 );
+		final int w = ( int ) interval.dimension( 0 );
+		final int h = ( int ) interval.dimension( 1 );
+		g.drawRect( x, y, w, h );
+	}
+
+	private class RepaintIntervalBehaviour implements DragBehaviour
+	{
+		@Override
+		public void init( final int x, final int y )
+		{
+			repaint( x, y );
+		}
+
+		@Override
+		public void drag( final int x, final int y )
+		{
+			repaint( x, y );
+		}
+
+		@Override
+		public void end( final int x, final int y )
+		{
+			repaint( -1, -1 );
+		}
+	}
+
+	private class Overlay implements OverlayRenderer
+	{
+		@Override
+		public void drawOverlays( final Graphics g )
+		{
+			drawOverlay( ( Graphics2D ) g );
+		}
+
+		@Override
+		public void setCanvasSize( final int width, final int height )
+		{
+		}
+	}
+}
diff --git a/src/test/java/bdv/ui/CardPanelExample.java b/src/test/java/bdv/ui/CardPanelExample.java
new file mode 100644
index 0000000000000000000000000000000000000000..1520ecd3fda44519f6af10e49d80e7a95e85cca5
--- /dev/null
+++ b/src/test/java/bdv/ui/CardPanelExample.java
@@ -0,0 +1,99 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.ui;
+
+import java.awt.Dimension;
+import java.awt.event.ActionEvent;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Random;
+import javax.swing.AbstractAction;
+import javax.swing.JButton;
+import javax.swing.JFrame;
+import javax.swing.JLabel;
+import javax.swing.WindowConstants;
+import net.miginfocom.swing.MigLayout;
+
+/**
+ * Card panel example.
+ *
+ * @author Tim-Oliver Buchholz, MPI-CBG CSBD, Dresden
+ */
+public class CardPanelExample
+{
+
+	public static void main( String[] args )
+	{
+		System.setProperty( "apple.laf.useScreenMenuBar", "true" );
+
+		final JFrame frame = new JFrame( "CardPanel Example" );
+		frame.setLayout( new MigLayout( "fillx", "[]", "" ) );
+		final JButton add = new JButton( "Add Card" );
+		final JButton remove = new JButton( "Remove Card" );
+		final JButton toggle = new JButton( "Toggle Card" );
+		frame.add( add, "growx, wrap" );
+		frame.add( remove, "growx, wrap" );
+		frame.add( toggle, "growx, wrap" );
+
+		final CardPanel cardPanel = new CardPanel();
+
+		frame.setPreferredSize( new Dimension( 200, 300 ) );
+		frame.add( cardPanel.getComponent(), "growx, growy" );
+		frame.pack();
+		frame.setDefaultCloseOperation( WindowConstants.DISPOSE_ON_CLOSE );
+		frame.setVisible( true );
+
+		final Random rand = new Random();
+		final List< String > names = new ArrayList<>();
+
+		add.addActionListener( e ->
+		{
+			final String name = "Card " + rand.nextInt();
+			cardPanel.addCard( name, new JLabel( "Conent " + rand.nextFloat() ), rand.nextBoolean() );
+			names.add( name );
+		} );
+		remove.addActionListener( e->
+		{
+			if ( names.size() > 0 )
+			{
+				final int idx = rand.nextInt( names.size() );
+				cardPanel.removeCard( names.get( idx ) );
+				names.remove( idx );
+			}
+		} );
+		toggle.addActionListener( e ->
+		{
+			if ( names.size() > 0 )
+			{
+				final int idx = rand.nextInt( names.size() );
+				cardPanel.setCardExpanded( names.get( idx ), !cardPanel.isCardExpanded( names.get( idx ) ) );
+			}
+		} );
+	}
+}
diff --git a/src/test/java/bdv/ui/CreateViewerState.java b/src/test/java/bdv/ui/CreateViewerState.java
new file mode 100644
index 0000000000000000000000000000000000000000..678c91b75a0d4fe86ff723da647b9a3af4eb314e
--- /dev/null
+++ b/src/test/java/bdv/ui/CreateViewerState.java
@@ -0,0 +1,167 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.ui;
+
+import bdv.tools.brightness.ConverterSetup;
+import bdv.viewer.Interpolation;
+import bdv.viewer.Source;
+import bdv.viewer.SourceAndConverter;
+import java.util.Random;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.IntPredicate;
+import mpicbg.spim.data.sequence.VoxelDimensions;
+import net.imglib2.RandomAccessibleInterval;
+import net.imglib2.RealRandomAccessible;
+import net.imglib2.realtransform.AffineTransform3D;
+import net.imglib2.type.numeric.ARGBType;
+import net.imglib2.type.numeric.integer.UnsignedShortType;
+
+/**
+ * Creates dummy sources and converters for testing SourceTable and SourceGroupTree
+ *
+ * @author Tobias Pietzsch
+ */
+public class CreateViewerState
+{
+	static class TestSource implements Source< UnsignedShortType >
+	{
+		private static final AtomicInteger id = new AtomicInteger();
+
+		private static final Random random = new Random();
+
+		private static String nextName()
+		{
+			return "source(" + id.getAndIncrement() + ")";
+		}
+
+		private final String name;
+
+		private final IntPredicate isPresent;
+
+		private final DefaultConverterSetup converterSetup;
+
+		public TestSource()
+		{
+			this( nextName(), i -> false );
+		}
+
+		public TestSource( final String name )
+		{
+			this( name, i -> false );
+		}
+
+		public TestSource( final IntPredicate isPresent )
+		{
+			this( nextName(), isPresent );
+		}
+
+		public TestSource( final String name, final IntPredicate isPresent )
+		{
+			this.name = name;
+			this.isPresent = isPresent;
+
+			final DefaultConverterSetup s = new DefaultConverterSetup( id.get(), random.nextBoolean() );
+			final double a = random.nextDouble() * 65535;
+			final double b = random.nextDouble() * 65535;
+			s.setDisplayRange( Math.min( a, b ), Math.max( a, b ) );
+			s.setColor( new ARGBType( random.nextInt() ) );
+			converterSetup = s;
+		}
+
+		@Override
+		public UnsignedShortType getType()
+		{
+			return new UnsignedShortType();
+		}
+
+		@Override
+		public String getName()
+		{
+			return name;
+		}
+
+		@Override
+		public VoxelDimensions getVoxelDimensions()
+		{
+			return null;
+		}
+
+		@Override
+		public int getNumMipmapLevels()
+		{
+			return 1;
+		}
+
+		@Override
+		public boolean isPresent( final int t )
+		{
+			return isPresent.test( t );
+		}
+
+		@Override
+		public RandomAccessibleInterval< UnsignedShortType > getSource( final int t, final int level )
+		{
+			return null;
+		}
+
+		@Override
+		public RealRandomAccessible< UnsignedShortType > getInterpolatedSource( final int t, final int level, final Interpolation method )
+		{
+			return null;
+		}
+
+		@Override
+		public void getSourceTransform( final int t, final int level, final AffineTransform3D transform )
+		{
+			transform.identity();
+		}
+
+		/**
+		 * ConverterSetups need to be associated to Sources somehow.
+		 */
+		public DefaultConverterSetup getConverterSetup()
+		{
+			return converterSetup;
+		}
+	}
+
+	public static SourceAndConverter< ? > createSource()
+	{
+		final TestSource source = new TestSource();
+		return new SourceAndConverter<>( source, source.getConverterSetup() );
+	}
+
+	public static ConverterSetup getConverterSetup( SourceAndConverter< ? > source )
+	{
+		if ( source.getSpimSource() instanceof TestSource )
+			return ( ( TestSource ) source.getSpimSource() ).getConverterSetup();
+		else
+			return null;
+	};
+}
diff --git a/src/test/java/bdv/ui/DefaultConverterSetup.java b/src/test/java/bdv/ui/DefaultConverterSetup.java
new file mode 100644
index 0000000000000000000000000000000000000000..2cac6e3798c4e8f75a2a4d9289fb0561c961222a
--- /dev/null
+++ b/src/test/java/bdv/ui/DefaultConverterSetup.java
@@ -0,0 +1,126 @@
+/*
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.ui;
+
+import bdv.tools.brightness.ConverterSetup;
+import net.imglib2.converter.Converter;
+import net.imglib2.type.numeric.ARGBType;
+import net.imglib2.type.numeric.integer.UnsignedShortType;
+import org.scijava.listeners.Listeners;
+
+/**
+ * Dummy Converter and ConverterSetup for testing SourceTable and SourceGroupTree
+ *
+ * @author Tobias Pietzsch
+ */
+public class DefaultConverterSetup implements ConverterSetup, Converter< UnsignedShortType, ARGBType >
+{
+	private final int setupId;
+
+	private final boolean supportsColor;
+
+	private double displayRangeMin;
+
+	private double displayRangeMax;
+
+	private final ARGBType color = new ARGBType();
+
+	private final Listeners.List< SetupChangeListener > listeners = new Listeners.SynchronizedList<>();
+
+	public DefaultConverterSetup( final int setupId, final boolean supportsColor )
+	{
+		this.setupId = setupId;
+		this.supportsColor = supportsColor;
+		this.displayRangeMin = 0;
+		this.displayRangeMax = 1;
+	}
+
+	@Override
+	public Listeners< SetupChangeListener > setupChangeListeners()
+	{
+		return listeners;
+	}
+
+	@Override
+	public synchronized void setDisplayRange( final double min, final double max )
+	{
+		if ( displayRangeMin != min || displayRangeMax != max )
+		{
+			displayRangeMin = min;
+			displayRangeMax = max;
+			listeners.list.forEach( l -> l.setupParametersChanged( this ) );
+		}
+	}
+
+	@Override
+	public synchronized void setColor( final ARGBType argb )
+	{
+		if ( supportsColor && color.get() != argb.get() )
+		{
+			color.set( argb );
+			listeners.list.forEach( l -> l.setupParametersChanged( this ) );
+		}
+	}
+
+	@Override
+	public boolean supportsColor()
+	{
+		return supportsColor;
+	}
+
+	@Override
+	public int getSetupId()
+	{
+		return setupId;
+	}
+
+	@Override
+	public double getDisplayRangeMin()
+	{
+		return displayRangeMin;
+	}
+
+	@Override
+	public double getDisplayRangeMax()
+	{
+		return displayRangeMax;
+	}
+
+	@Override
+	public ARGBType getColor()
+	{
+		return color;
+	}
+
+	@Override
+	public void convert( final UnsignedShortType input, final ARGBType output )
+	{
+		throw new UnsupportedOperationException();
+	}
+}
diff --git a/src/test/java/bdv/ui/PlaygroundCards.java b/src/test/java/bdv/ui/PlaygroundCards.java
new file mode 100644
index 0000000000000000000000000000000000000000..a858ea03c9577886bd1675f4f47dd1aac0a236b1
--- /dev/null
+++ b/src/test/java/bdv/ui/PlaygroundCards.java
@@ -0,0 +1,209 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.ui;
+
+import bdv.ui.sourcetable.SourceTable;
+import bdv.ui.sourcegrouptree.SourceGroupTree;
+import bdv.ui.convertersetupeditor.ConverterSetupEditPanel;
+import bdv.viewer.BasicViewerState;
+import bdv.viewer.ConverterSetups;
+import bdv.viewer.SourceAndConverter;
+import bdv.viewer.SourceGroup;
+import bdv.viewer.SynchronizedViewerState;
+import bdv.viewer.ViewerState;
+import java.awt.BorderLayout;
+import java.awt.Container;
+import java.awt.Dimension;
+import java.awt.Insets;
+import java.awt.KeyboardFocusManager;
+import java.beans.PropertyChangeEvent;
+import java.beans.PropertyChangeListener;
+import java.util.ArrayList;
+import java.util.List;
+import javax.swing.JComponent;
+import javax.swing.JFrame;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.border.EmptyBorder;
+import javax.swing.tree.TreeSelectionModel;
+
+public class PlaygroundCards
+{
+	public static void main( final String[] args )
+	{
+		// create a ViewerState to show
+		final ViewerState state = new SynchronizedViewerState( new BasicViewerState() );
+		final List< SourceAndConverter< ? > > sources = new ArrayList<>();
+		for ( int i = 0; i < 10; ++i )
+			sources.add( CreateViewerState.createSource() );
+		state.addSources( sources );
+		state.setSourceActive( sources.get( 1 ), true );
+		state.setSourceActive( sources.get( 2 ), true );
+		state.setSourceActive( sources.get( 7 ), true );
+
+		final List< SourceGroup > groups = new ArrayList<>();
+		for ( int i = 0; i < 10; ++i )
+		{
+			final SourceGroup g = new SourceGroup();
+			groups.add( g );
+			state.addGroup( g );
+			state.setGroupName( g, "group(" + i + ")" );
+		}
+		state.setGroupActive( groups.get( 1 ), true );
+		state.setGroupActive( groups.get( 2 ), true );
+		state.setGroupActive( groups.get( 7 ), true );
+
+		state.addSourceToGroup( sources.get( 0 ), groups.get( 0 ) );
+		state.addSourceToGroup( sources.get( 1 ), groups.get( 0 ) );
+		state.addSourceToGroup( sources.get( 2 ), groups.get( 0 ) );
+		state.addSourceToGroup( sources.get( 3 ), groups.get( 3 ) );
+		state.addSourceToGroup( sources.get( 8 ), groups.get( 3 ) );
+		state.addSourceToGroup( sources.get( 4 ), groups.get( 3 ) );
+		state.addSourceToGroup( sources.get( 2 ), groups.get( 3 ) );
+
+		final ConverterSetups converterSetups = new ConverterSetups( state );
+		for ( SourceAndConverter< ? > source : sources )
+			converterSetups.put( source, CreateViewerState.getConverterSetup( source ) );
+
+		// -- Sources table --
+
+		final SourceTable table = new SourceTable( state, converterSetups );
+		table.setPreferredScrollableViewportSize( new Dimension( 400, 400 ) );
+		table.setFillsViewportHeight( true );
+		table.setDragEnabled( true );
+
+		final ConverterSetupEditPanel editPanelTable = new ConverterSetupEditPanel( table, converterSetups );
+
+		final JPanel tablePanel = new JPanel( new BorderLayout() );
+		final JScrollPane scrollPaneTable = new JScrollPane( table );
+		scrollPaneTable.setBorder( new EmptyBorder( 0, 0, 0, 0 ) );
+		tablePanel.add( scrollPaneTable, BorderLayout.CENTER );
+		tablePanel.add( editPanelTable, BorderLayout.SOUTH );
+
+
+		// -- Groups tree --
+
+		SourceGroupTree tree = new SourceGroupTree( state );
+		tree.setEditable( true );
+		tree.setSelectionRow( 0 );
+		tree.setRootVisible( false );
+		tree.setShowsRootHandles( true );
+		tree.setExpandsSelectedPaths( true );
+		tree.getSelectionModel().setSelectionMode( TreeSelectionModel.DISCONTIGUOUS_TREE_SELECTION );
+
+		final ConverterSetupEditPanel editPanelTree = new ConverterSetupEditPanel( tree, converterSetups );
+
+		final JPanel treePanel = new JPanel( new BorderLayout() );
+		final JScrollPane scrollPaneTree = new JScrollPane( tree );
+		scrollPaneTree.setBorder( new EmptyBorder( 0, 0, 0, 0 ) );
+		treePanel.add( scrollPaneTree, BorderLayout.CENTER );
+		treePanel.add( editPanelTree, BorderLayout.SOUTH );
+
+
+		// -- handle focus --
+
+		KeyboardFocusManager.getCurrentKeyboardFocusManager().addPropertyChangeListener( "focusOwner", new PropertyChangeListener()
+		{
+			static final int MAX_DEPTH = 7;
+			boolean tableFocused;
+			boolean treeFocused;
+
+			void focusTable( boolean focus )
+			{
+				if ( focus != tableFocused )
+				{
+					tableFocused = focus;
+					table.setSelectionBackground( focus );
+				}
+			}
+
+			void focusTree( boolean focus )
+			{
+				if ( focus != treeFocused )
+				{
+					treeFocused = focus;
+					tree.setSelectionBackground( focus );
+				}
+			}
+
+			@Override
+			public void propertyChange( final PropertyChangeEvent evt )
+			{
+				if ( evt.getNewValue() instanceof JComponent )
+				{
+					JComponent component = ( JComponent ) evt.getNewValue();
+					for ( int i = 0; i < MAX_DEPTH; ++i )
+					{
+						Container parent = component.getParent();
+						if ( ! ( parent instanceof JComponent ) )
+							break;
+
+						component = ( JComponent ) parent;
+//						System.out.println( " -> " + component );
+						if ( component == treePanel && !treeFocused )
+						{
+							focusTable( false );
+							focusTree( true );
+							return;
+						}
+						else if ( component == tablePanel )
+						{
+							focusTable( true );
+							focusTree( false );
+							return;
+						}
+					}
+					focusTable( false );
+					focusTree( false );
+				}
+			}
+		} );
+
+
+		// -- cards --
+
+		CardPanel cardPanel = new CardPanel();
+		cardPanel.addCard( "Sources", tablePanel, true, new Insets( 0, 4, 0, 0  ) );
+		cardPanel.addCard( "Groups", treePanel, true, new Insets( 0, 4, 0, 0  ) );
+
+
+		//Create and set up the window.
+		final JFrame frame = new JFrame( "Sources Table" );
+		frame.setDefaultCloseOperation( JFrame.EXIT_ON_CLOSE );
+		frame.getRootPane().setDoubleBuffered( true );
+		final JScrollPane scrollPane = new JScrollPane( cardPanel.getComponent() );
+		scrollPane.setHorizontalScrollBarPolicy( JScrollPane.HORIZONTAL_SCROLLBAR_NEVER );
+		scrollPane.getVerticalScrollBar().setUnitIncrement(16);
+		frame.add( scrollPane, BorderLayout.CENTER );
+//		frame.add( cardPanel.getComponent(), BorderLayout.CENTER );
+		frame.pack();
+		frame.setVisible( true );
+	}
+
+}
diff --git a/src/test/java/bdv/ui/PlaygroundSourcesDragAndDrop.java b/src/test/java/bdv/ui/PlaygroundSourcesDragAndDrop.java
new file mode 100644
index 0000000000000000000000000000000000000000..652858a57c85d53555dfb59fc855dcc4946884e7
--- /dev/null
+++ b/src/test/java/bdv/ui/PlaygroundSourcesDragAndDrop.java
@@ -0,0 +1,145 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.ui;
+
+import bdv.ui.sourcetable.SourceTable;
+import bdv.ui.sourcegrouptree.SourceGroupTree;
+import bdv.viewer.BasicViewerState;
+import bdv.viewer.ConverterSetups;
+import bdv.viewer.SourceAndConverter;
+import bdv.viewer.SourceGroup;
+import java.awt.BorderLayout;
+import java.awt.Dimension;
+import java.awt.event.FocusEvent;
+import java.awt.event.FocusListener;
+import java.util.ArrayList;
+import java.util.List;
+import javax.swing.JFrame;
+import javax.swing.JScrollPane;
+import javax.swing.tree.TreeSelectionModel;
+
+public class PlaygroundSourcesDragAndDrop
+{
+	public static void main( String[] args )
+	{
+		// create a ViewerState to show
+		final BasicViewerState state = new BasicViewerState();
+		final List< SourceAndConverter< ? > > sources = new ArrayList<>();
+		for ( int i = 0; i < 10; ++i )
+			sources.add( CreateViewerState.createSource() );
+		state.addSources( sources );
+		state.setSourceActive( sources.get( 1 ), true );
+		state.setSourceActive( sources.get( 2 ), true );
+		state.setSourceActive( sources.get( 7 ), true );
+
+		final List< SourceGroup > groups = new ArrayList<>();
+		for ( int i = 0; i < 10; ++i )
+		{
+			final SourceGroup g = new SourceGroup();
+			groups.add( g );
+			state.addGroup( g );
+			state.setGroupName( g, "group(" + i + ")" );
+		}
+		state.setGroupActive( groups.get( 1 ), true );
+		state.setGroupActive( groups.get( 2 ), true );
+		state.setGroupActive( groups.get( 7 ), true );
+
+		state.addSourceToGroup( sources.get( 0 ), groups.get( 0 ) );
+		state.addSourceToGroup( sources.get( 1 ), groups.get( 0 ) );
+		state.addSourceToGroup( sources.get( 2 ), groups.get( 0 ) );
+		state.addSourceToGroup( sources.get( 3 ), groups.get( 3 ) );
+		state.addSourceToGroup( sources.get( 8 ), groups.get( 3 ) );
+		state.addSourceToGroup( sources.get( 4 ), groups.get( 3 ) );
+		state.addSourceToGroup( sources.get( 2 ), groups.get( 3 ) );
+
+		final ConverterSetups converterSetups = new ConverterSetups( state );
+		for ( SourceAndConverter< ? > source : sources )
+			converterSetups.put( source, CreateViewerState.getConverterSetup( source ) );
+
+		// create the table
+		SourceTable table = new SourceTable( state, converterSetups );
+		table.setPreferredScrollableViewportSize( new Dimension( 400, 200 ) );
+		table.setFillsViewportHeight( true );
+		table.setDragEnabled( true );
+		table.setDropTarget( null );
+
+		// create the tree
+		SourceGroupTree tree = new SourceGroupTree( state );
+		tree.setEditable( true );
+		tree.setSelectionRow( 0 );
+		tree.setRootVisible( false );
+		tree.setShowsRootHandles( true );
+		tree.setExpandsSelectedPaths( true );
+		tree.getSelectionModel().setSelectionMode( TreeSelectionModel.DISCONTIGUOUS_TREE_SELECTION );
+//		tree.setDropMode( DropMode.ON );
+
+
+		// handle focus
+		table.addFocusListener( new FocusListener()
+		{
+			@Override
+			public void focusGained( final FocusEvent e )
+			{
+				table.setSelectionBackground( true );
+			}
+
+			@Override
+			public void focusLost( final FocusEvent e )
+			{
+				table.setSelectionBackground( false );
+			}
+		} );
+		tree.addFocusListener( new FocusListener()
+		{
+			@Override
+			public void focusGained( final FocusEvent e )
+			{
+				tree.setSelectionBackground( true );
+			}
+
+			@Override
+			public void focusLost( final FocusEvent e )
+			{
+				tree.setSelectionBackground( false );
+			}
+		} );
+
+
+		//Create and set up the window.
+		JFrame frame = new JFrame( "Sources Table" );
+		frame.setDefaultCloseOperation( JFrame.EXIT_ON_CLOSE );
+		frame.getRootPane().setDoubleBuffered( true );
+		JScrollPane scrollPane = new JScrollPane( table );
+		frame.add( scrollPane, BorderLayout.NORTH );
+		frame.add( tree, BorderLayout.CENTER );
+		frame.pack();
+		frame.setVisible( true );
+	}
+
+}
diff --git a/src/test/java/bdv/ui/sourcegrouptree/PlaygroundTree.java b/src/test/java/bdv/ui/sourcegrouptree/PlaygroundTree.java
new file mode 100644
index 0000000000000000000000000000000000000000..5b2bb21fd5c8f05a1fc0655780d6c4c9292628ca
--- /dev/null
+++ b/src/test/java/bdv/ui/sourcegrouptree/PlaygroundTree.java
@@ -0,0 +1,136 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.ui.sourcegrouptree;
+
+import bdv.ui.CreateViewerState;
+import bdv.ui.sourcegrouptree.SourceGroupTree;
+import bdv.ui.sourcegrouptree.SourceGroupTreeModel.GroupModel;
+import bdv.viewer.BasicViewerState;
+import bdv.viewer.SourceAndConverter;
+import bdv.viewer.SourceGroup;
+import java.awt.BorderLayout;
+import java.util.ArrayList;
+import java.util.List;
+import javax.swing.BoxLayout;
+import javax.swing.JButton;
+import javax.swing.JFrame;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.tree.TreeSelectionModel;
+
+public class PlaygroundTree
+{
+	public static void main( String[] args )
+	{
+//		try
+//		{
+//			UIManager.setLookAndFeel( UIManager.getCrossPlatformLookAndFeelClassName());
+//		}
+//		catch ( ClassNotFoundException | InstantiationException | IllegalAccessException | UnsupportedLookAndFeelException e )
+//		{
+//			e.printStackTrace();
+//		}
+
+		// create a ViewerState to show
+		final BasicViewerState state = new BasicViewerState();
+		final List< SourceAndConverter< ? > > sources = new ArrayList<>();
+		for ( int i = 0; i < 10; ++i )
+			sources.add( CreateViewerState.createSource() );
+		state.addSources( sources );
+
+		final List< SourceGroup > groups = new ArrayList<>();
+		for ( int i = 0; i < 10; ++i )
+		{
+			final SourceGroup g = new SourceGroup();
+			groups.add( g );
+			state.addGroup( g );
+			state.setGroupName( g, "group(" + i + ")" );
+		}
+		state.setGroupActive( groups.get( 1 ), true );
+		state.setGroupActive( groups.get( 2 ), true );
+		state.setGroupActive( groups.get( 7 ), true );
+
+		state.addSourceToGroup( sources.get( 0 ), groups.get( 0 ) );
+		state.addSourceToGroup( sources.get( 1 ), groups.get( 0 ) );
+		state.addSourceToGroup( sources.get( 2 ), groups.get( 0 ) );
+		state.addSourceToGroup( sources.get( 3 ), groups.get( 3 ) );
+		state.addSourceToGroup( sources.get( 8 ), groups.get( 3 ) );
+		state.addSourceToGroup( sources.get( 4 ), groups.get( 3 ) );
+		state.addSourceToGroup( sources.get( 2 ), groups.get( 3 ) );
+
+		// create the tree
+		SourceGroupTree tree = new SourceGroupTree( state );
+		tree.setEditable( true );
+		tree.setSelectionRow( 0 );
+		tree.setRootVisible( false );
+		tree.setShowsRootHandles( true );
+		tree.setExpandsSelectedPaths( true );
+		tree.getSelectionModel().setSelectionMode( TreeSelectionModel.DISCONTIGUOUS_TREE_SELECTION );
+//		tree.setToggleClickCount( 0 );
+//		tree.setSelectionModel(  );
+
+		JPanel buttons = new JPanel();
+		buttons.setLayout( new BoxLayout( buttons, BoxLayout.Y_AXIS ) );
+		final JButton toggle5Active = new JButton("a5");
+		toggle5Active.addActionListener( e -> state.setGroupActive( groups.get( 5 ), !state.isGroupActive( groups.get( 5 ) ) ) );
+		final JButton add5to3 = new JButton("5->3");
+		add5to3.addActionListener( e -> state.addSourceToGroup( sources.get( 5 ), groups.get( 3 ) ) );
+		final JButton remove02from0 = new JButton("0,2x>0");
+		remove02from0.addActionListener( e ->
+		{
+			state.removeSourceFromGroup( sources.get( 0 ), groups.get( 0 ) );
+			state.removeSourceFromGroup( sources.get( 2 ), groups.get( 0 ) );
+		} );
+		final JButton name4 = new JButton("rename4");
+		name4.addActionListener( e -> state.setGroupName( groups.get( 4 ), "renamed group 4" ) );
+		final JButton remove0 = new JButton("x0");
+		remove0.addActionListener( e -> state.removeGroup( groups.get( 0 ) ) );
+		final JButton remove4 = new JButton("x4");
+		remove4.addActionListener( e -> state.removeGroup( groups.get( 4 ) ) );
+		final JButton edit4 = new JButton("e4");
+		edit4.addActionListener( e -> tree.startEditingAtPath( tree.getPathTo( groups.get( 4 ) ) ) );
+		buttons.add( toggle5Active );
+		buttons.add( add5to3 );
+		buttons.add( remove02from0 );
+		buttons.add( name4 );
+		buttons.add( remove0 );
+		buttons.add( remove4 );
+		buttons.add( edit4 );
+
+		//Create and set up the window.
+		JFrame frame = new JFrame( "Groups Tree" );
+		frame.setDefaultCloseOperation( JFrame.EXIT_ON_CLOSE );
+		frame.getRootPane().setDoubleBuffered( true );
+		JScrollPane scrollPane = new JScrollPane( tree );
+		frame.add( scrollPane, BorderLayout.CENTER );
+		frame.add( buttons, BorderLayout.EAST );
+		frame.pack();
+		frame.setVisible( true );
+	}
+}
diff --git a/src/test/java/bdv/ui/sourcetable/PlaygroundTable.java b/src/test/java/bdv/ui/sourcetable/PlaygroundTable.java
new file mode 100644
index 0000000000000000000000000000000000000000..60063b2e014e445e5f6df9e6c6204daca6e3fa1c
--- /dev/null
+++ b/src/test/java/bdv/ui/sourcetable/PlaygroundTable.java
@@ -0,0 +1,91 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.ui.sourcetable;
+
+import bdv.ui.CreateViewerState;
+import bdv.ui.sourcetable.SourceTable;
+import bdv.ui.convertersetupeditor.ConverterSetupEditPanel;
+import bdv.viewer.BasicViewerState;
+import bdv.viewer.ConverterSetups;
+import bdv.viewer.SourceAndConverter;
+import java.awt.BorderLayout;
+import java.awt.Dimension;
+import java.util.ArrayList;
+import java.util.List;
+import javax.swing.JFrame;
+import javax.swing.JScrollPane;
+
+public class PlaygroundTable
+{
+	public static void main( final String[] args )
+	{
+//		try
+//		{
+//			UIManager.setLookAndFeel( UIManager.getCrossPlatformLookAndFeelClassName());
+//		}
+//		catch ( ClassNotFoundException | InstantiationException | IllegalAccessException | UnsupportedLookAndFeelException e )
+//		{
+//			e.printStackTrace();
+//		}
+
+		// create a ViewerState to show
+		final BasicViewerState state = new BasicViewerState();
+		final List< SourceAndConverter< ? > > sources = new ArrayList<>();
+		for ( int i = 0; i < 10; ++i )
+			sources.add( CreateViewerState.createSource() );
+		state.addSources( sources );
+		state.setSourceActive( sources.get( 1 ), true );
+		state.setSourceActive( sources.get( 2 ), true );
+		state.setSourceActive( sources.get( 7 ), true );
+
+		final ConverterSetups converterSetups = new ConverterSetups( state );
+		for ( SourceAndConverter< ? > source : sources )
+			converterSetups.put( source, CreateViewerState.getConverterSetup( source ) );
+
+
+		// TODO: create the table
+		final SourceTable table = new SourceTable( state, converterSetups );
+		table.setPreferredScrollableViewportSize( new Dimension( 400, 800 ) );
+		table.setFillsViewportHeight( true );
+		table.setDragEnabled( true );
+
+		final ConverterSetupEditPanel editPanel = new ConverterSetupEditPanel( table, converterSetups );
+
+		//Create and set up the window.
+		final JFrame frame = new JFrame( "Sources Table" );
+		frame.setDefaultCloseOperation( JFrame.EXIT_ON_CLOSE );
+		frame.getRootPane().setDoubleBuffered( true );
+		final JScrollPane scrollPane = new JScrollPane( table );
+		frame.add( scrollPane, BorderLayout.CENTER );
+		frame.add( editPanel, BorderLayout.SOUTH );
+		frame.pack();
+		frame.setVisible( true );
+	}
+}
+
diff --git a/src/test/java/bdv/ui/splitpanel/SplitPanelExample.java b/src/test/java/bdv/ui/splitpanel/SplitPanelExample.java
new file mode 100644
index 0000000000000000000000000000000000000000..ccbddc46e7c3ea8a875c298c9531004e35673b8a
--- /dev/null
+++ b/src/test/java/bdv/ui/splitpanel/SplitPanelExample.java
@@ -0,0 +1,62 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.ui.splitpanel;
+
+import bdv.cache.CacheControl;
+import bdv.ui.CardPanel;
+import bdv.viewer.ViewerOptions;
+import bdv.viewer.ViewerPanel;
+import java.awt.Dimension;
+import java.util.ArrayList;
+import javax.swing.JFrame;
+import javax.swing.WindowConstants;
+import net.miginfocom.swing.MigLayout;
+
+public class SplitPanelExample
+{
+	public static void main( String[] args )
+	{
+		System.setProperty( "apple.laf.useScreenMenuBar", "true" );
+
+		final JFrame frame = new JFrame( "SplitPanel Example" );
+		frame.setLayout( new MigLayout( "ins 0, fillx, filly", "[grow]", "" ) );
+
+		final CardPanel cardPanel = new CardPanel();
+		final ViewerPanel viewerPanel = new ViewerPanel( new ArrayList<>(), 1, new CacheControl.Dummy(), ViewerOptions.options().width( 600 ) );
+
+		final SplitPanel splitPanel = new SplitPanel( viewerPanel, cardPanel );
+		splitPanel.setBorder( null );
+
+		frame.setPreferredSize( new Dimension( 800, 600 ) );
+		frame.add( splitPanel, "growx, growy" );
+		frame.pack();
+		frame.setDefaultCloseOperation( WindowConstants.DISPOSE_ON_CLOSE );
+		frame.setVisible( true );
+	}
+}
diff --git a/src/test/java/bdv/util/BoundedRangeTest.java b/src/test/java/bdv/util/BoundedRangeTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..34685aa9db2306653e5bd46cc0830f6a70afc6be
--- /dev/null
+++ b/src/test/java/bdv/util/BoundedRangeTest.java
@@ -0,0 +1,144 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.util;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+public class BoundedRangeTest
+{
+	@Test
+	public void testConstructor()
+	{
+		new BoundedRange( 0, 1, 0, 1 );
+	}
+
+	@Test( expected = IllegalArgumentException.class )
+	public void testConstructorFails1()
+	{
+		new BoundedRange( 0, 1, -1, 1 );
+	}
+
+	@Test( expected = IllegalArgumentException.class )
+	public void testConstructorFails2()
+	{
+		new BoundedRange( 0, -1, 0, 0 );
+	}
+
+	@Test( expected = IllegalArgumentException.class )
+	public void testConstructorFails3()
+	{
+		new BoundedRange( 0, 11, 1, 0 );
+	}
+
+	@Test
+	public void testWithMin()
+	{
+		Assert.assertEquals(
+				new BoundedRange( 0, 1, 0, 1 ).withMin( -1 ),
+				new BoundedRange( -1, 1, -1, 1 )
+		);
+		Assert.assertEquals(
+				new BoundedRange( 0, 1, 0, 1 ).withMin( 0.5 ),
+				new BoundedRange( 0, 1, 0.5, 1 )
+		);
+		Assert.assertEquals(
+				new BoundedRange( 0, 1, 0, 1 ).withMin( 2 ),
+				new BoundedRange( 0, 2, 2, 2 )
+		);
+	}
+
+	@Test
+	public void testWithMax()
+	{
+		Assert.assertEquals(
+				new BoundedRange( 0, 1, 0, 1 ).withMax( 2 ),
+				new BoundedRange( 0, 2, 0, 2 )
+		);
+		Assert.assertEquals(
+				new BoundedRange( 0, 1, 0, 1 ).withMax( 0.5 ),
+				new BoundedRange( 0, 1, 0, 0.5 )
+		);
+		Assert.assertEquals(
+				new BoundedRange( 0, 1, 0, 1 ).withMax( -1 ),
+				new BoundedRange( -1, 1, -1, -1 )
+		);
+	}
+
+	@Test
+	public void testWithMinBound()
+	{
+		Assert.assertEquals(
+				new BoundedRange( 0, 3, 1, 2 ).withMinBound( -1 ),
+				new BoundedRange( -1, 3, 1, 2 )
+		);
+		Assert.assertEquals(
+				new BoundedRange( 0, 3, 1, 2 ).withMinBound( 0.5 ),
+				new BoundedRange( 0.5, 3, 1, 2 )
+		);
+		Assert.assertEquals(
+				new BoundedRange( 0, 3, 1, 2 ).withMinBound( 1.5 ),
+				new BoundedRange( 1.5, 3, 1.5, 2 )
+		);
+		Assert.assertEquals(
+				new BoundedRange( 0, 3, 1, 2 ).withMinBound( 4 ),
+				new BoundedRange( 4, 4, 4, 4 )
+		);
+	}
+
+	@Test
+	public void testWithMaxBound()
+	{
+		Assert.assertEquals(
+				new BoundedRange( 0, 3, 1, 2 ).withMaxBound( 4 ),
+				new BoundedRange( 0, 4, 1, 2 )
+		);
+		Assert.assertEquals(
+				new BoundedRange( 0, 3, 1, 2 ).withMaxBound( 2.5 ),
+				new BoundedRange( 0, 2.5, 1, 2 )
+		);
+		Assert.assertEquals(
+				new BoundedRange( 0, 3, 1, 2 ).withMaxBound( 1.5 ),
+				new BoundedRange( 0, 1.5, 1, 1.5 )
+		);
+		Assert.assertEquals(
+				new BoundedRange( 0, 3, 1, 2 ).withMaxBound( -1 ),
+				new BoundedRange( -1, -1, -1, -1 )
+		);
+	}
+
+	@Test
+	public void testJoin()
+	{
+		Assert.assertEquals(
+				new BoundedRange( 0, 3, 1, 2 ).join( new BoundedRange( 5, 8, 6, 7 ) ),
+				new BoundedRange( 0, 8, 1, 7 )
+		);
+	}
+}
diff --git a/src/test/java/bdv/util/MovingAverageTest.java b/src/test/java/bdv/util/MovingAverageTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..633c8f186d9725e453d771b8135a23bd6573f3c2
--- /dev/null
+++ b/src/test/java/bdv/util/MovingAverageTest.java
@@ -0,0 +1,64 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.util;
+
+import java.util.Arrays;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+
+public class MovingAverageTest
+{
+	@Test
+	public void testAverage()
+	{
+		final double[] values = new double[ 1000 ];
+		Arrays.setAll( values, i -> Math.random() );
+
+		final int width = 3;
+		final MovingAverage avg = new MovingAverage( width );
+		avg.init( 0 );
+
+		for ( int i = 0; i < values.length; ++i )
+		{
+			avg.add( values[ i ] );
+			double expected = average( values, i - width + 1, i + 1 );
+			assertEquals( expected, avg.getAverage(), 1e-6 );
+		}
+	}
+
+	private static double average( final double[] values, final int fromIndex, final int toIndex )
+	{
+		final int numValues = toIndex - fromIndex;
+		double sum = 0;
+		for ( int i = fromIndex; i < toIndex; i++ )
+			sum += i < 0 ? 0 : values[ i ];
+		return sum / numValues;
+	}
+}
diff --git a/src/test/java/bdv/util/TripleBufferDemo.java b/src/test/java/bdv/util/TripleBufferDemo.java
new file mode 100644
index 0000000000000000000000000000000000000000..621d8d64343a67a01739c82e7fe400de98f57b93
--- /dev/null
+++ b/src/test/java/bdv/util/TripleBufferDemo.java
@@ -0,0 +1,111 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.util;
+
+import java.awt.Graphics;
+
+import javax.swing.JComponent;
+import javax.swing.JFrame;
+
+import net.imglib2.display.screenimage.awt.ARGBScreenImage;
+import net.imglib2.img.array.ArrayCursor;
+import net.imglib2.type.numeric.ARGBType;
+
+/**
+ * Test {@link TripleBuffer}.
+ *
+ * @author Matthias Arzt
+ */
+public class TripleBufferDemo
+{
+	private static TripleBuffer< ARGBScreenImage > tripleBuffer = new TripleBuffer<>( () -> new ARGBScreenImage( 100, 100 ) );
+
+	private static ImageComponent imageComponent = new ImageComponent();
+
+	public static void main( final String... args ) throws InterruptedException
+	{
+		final JFrame frame = new JFrame();
+		frame.setSize( 100, 100 );
+		frame.add( imageComponent );
+		frame.setVisible( true );
+
+		new PainterThread().start();
+	}
+
+	private static class PainterThread extends Thread
+	{
+		@Override
+		public void run()
+		{
+			try
+			{
+				while ( true )
+				{
+					for ( double r = 0; r < 2 * Math.PI; r += 0.01 )
+					{
+						renderCircle( r );
+						Thread.sleep( 1 );
+						imageComponent.repaint();
+					}
+				}
+			}
+			catch ( final InterruptedException e )
+			{
+				e.printStackTrace();
+			}
+		}
+
+		private void renderCircle( final double r )
+		{
+			final ARGBScreenImage image = tripleBuffer.getWritableBuffer();
+			image.forEach( pixel -> pixel.set( 0xff00ff00 ) );
+			final ArrayCursor< ARGBType > cursor = image.cursor();
+			while ( cursor.hasNext() )
+			{
+				final ARGBType pixel = cursor.next();
+				final double x = cursor.getDoublePosition( 0 );
+				final double y = cursor.getDoublePosition( 1 );
+				pixel.set( ARGBType.rgba( Math.sin( x / 10 + r ) * 127 + 127, Math.cos( y / 10 + r ) * 127 + 127, 0.0, 255.0 ) );
+			}
+			tripleBuffer.doneWriting( image );
+		}
+	}
+
+	private static class ImageComponent extends JComponent
+	{
+		@Override
+		protected void paintComponent( final Graphics g )
+		{
+			final ARGBScreenImage image = tripleBuffer.getReadableBuffer().getBuffer();
+			if ( image != null )
+				g.drawImage( image.image(), 0, 0, getWidth(), getHeight(), null );
+		}
+	}
+}
+
diff --git a/src/test/java/bdv/util/TripleBufferTest.java b/src/test/java/bdv/util/TripleBufferTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..0ab01992b66e3c377cbebe29819bfc58cbfc8d66
--- /dev/null
+++ b/src/test/java/bdv/util/TripleBufferTest.java
@@ -0,0 +1,102 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.util;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+
+/**
+ * Test {@link TripleBuffer}.
+ *
+ * @author Matthias Arzt
+ */
+public class TripleBufferTest
+{
+	@Test
+	public void testSimple()
+	{
+		final TripleBuffer< ModifiableString > db = new TripleBuffer<>( ModifiableString::new );
+		db.getWritableBuffer().set( "Hello" );
+		db.doneWriting();
+		assertEquals( "Hello", db.getReadableBuffer().getBuffer().get() );
+	}
+
+	@Test
+	public void testWriteTwice()
+	{
+		final TripleBuffer< ModifiableString > db = new TripleBuffer<>( ModifiableString::new );
+		db.getWritableBuffer().set( "Hello" );
+		db.doneWriting();
+		db.getWritableBuffer().set( "World" );
+		db.doneWriting();
+		assertEquals( "World", db.getReadableBuffer().getBuffer().get() );
+	}
+
+	@Test
+	public void testReadTwice()
+	{
+		final TripleBuffer< ModifiableString > db = new TripleBuffer<>( ModifiableString::new );
+		db.getWritableBuffer().set( "Hello" );
+		db.doneWriting();
+		assertEquals( "Hello", db.getReadableBuffer().getBuffer().get() );
+		assertEquals( "Hello", db.getReadableBuffer().getBuffer().get() );
+	}
+
+	@Test
+	public void testThreeBuffers()
+	{
+		final TripleBuffer< ModifiableString > db = new TripleBuffer<>( ModifiableString::new );
+		db.getWritableBuffer().set( "1" );
+		db.doneWriting();
+		db.getReadableBuffer();
+		db.getWritableBuffer().set( "2" );
+		db.doneWriting();
+		db.getReadableBuffer();
+		db.getWritableBuffer().set( "3" );
+		db.doneWriting();
+		db.getReadableBuffer();
+		assertEquals( "1", db.getWritableBuffer().get() );
+	}
+
+	private static class ModifiableString
+	{
+		private String value = "";
+
+		public String get()
+		{
+			return value;
+		}
+
+		public void set( final String value )
+		{
+			this.value = value;
+		}
+	}
+}
diff --git a/src/test/java/bdv/viewer/BasicViewerStateTest.java b/src/test/java/bdv/viewer/BasicViewerStateTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..8ef2cf96fe54b2571f1b7093d3f47a1080f119a0
--- /dev/null
+++ b/src/test/java/bdv/viewer/BasicViewerStateTest.java
@@ -0,0 +1,411 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.viewer;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.IntPredicate;
+import mpicbg.spim.data.sequence.VoxelDimensions;
+import net.imglib2.RandomAccessibleInterval;
+import net.imglib2.RealRandomAccessible;
+import net.imglib2.realtransform.AffineTransform3D;
+import org.junit.Assert;
+import org.junit.Test;
+
+import static bdv.viewer.DisplayMode.FUSED;
+import static bdv.viewer.DisplayMode.SINGLE;
+import static bdv.viewer.Interpolation.NEARESTNEIGHBOR;
+import static bdv.viewer.Interpolation.NLINEAR;
+import static bdv.viewer.ViewerStateChange.CURRENT_GROUP_CHANGED;
+import static bdv.viewer.ViewerStateChange.CURRENT_SOURCE_CHANGED;
+import static bdv.viewer.ViewerStateChange.DISPLAY_MODE_CHANGED;
+import static bdv.viewer.ViewerStateChange.INTERPOLATION_CHANGED;
+import static bdv.viewer.ViewerStateChange.NUM_GROUPS_CHANGED;
+import static bdv.viewer.ViewerStateChange.NUM_SOURCES_CHANGED;
+import static bdv.viewer.ViewerStateChange.VISIBILITY_CHANGED;
+
+public class BasicViewerStateTest
+{
+	@Test
+	public void addRemoveSource()
+	{
+		final BasicViewerState state = new BasicViewerState();
+		final SourceAndConverter< ? > s = createSource();
+
+		state.addSource( s );
+		Assert.assertEquals( state.getSources(), Collections.singletonList( s ) );
+
+		state.removeSource( s );
+		Assert.assertEquals( state.getSources(), Collections.emptyList() );
+	}
+
+	@Test
+	public void addRemoveGroup()
+	{
+		final BasicViewerState state = new BasicViewerState();
+		final SourceGroup g = new SourceGroup();
+
+		state.addGroup( g );
+		Assert.assertEquals( state.getGroups(), Collections.singletonList( g ) );
+
+		state.removeGroup( g );
+		Assert.assertEquals( state.getGroups(), Collections.emptyList() );
+	}
+
+	@Test
+	public void setGroupName()
+	{
+		final BasicViewerState state = new BasicViewerState();
+		final SourceGroup g = new SourceGroup();
+		state.addGroup( g );
+
+		final String a_group_name = "a group name";
+		state.setGroupName( g, a_group_name );
+		Assert.assertEquals( state.getGroupName( g ), a_group_name );
+	}
+
+	@Test
+	public void addSourceToGroup()
+	{
+		final BasicViewerState state = new BasicViewerState();
+		final SourceGroup g = new SourceGroup();
+		state.addGroup( g );
+		final SourceAndConverter< ? > s = createSource();
+		state.addSource( s );
+
+		state.addSourceToGroup( s, g );
+		Assert.assertEquals( state.getSourcesInGroup( g ), Collections.singleton( s ) );
+
+		state.removeSourceFromGroup( s, g );
+		Assert.assertEquals( state.getSourcesInGroup( g ), Collections.emptySet() );
+
+		state.addSourceToGroup( s, g );
+		Assert.assertEquals( state.getSourcesInGroup( g ), Collections.singleton( s ) );
+
+		state.removeSourceFromGroup( s, g );
+		Assert.assertEquals( state.getSourcesInGroup( g ), Collections.emptySet() );
+	}
+
+	@Test
+	public void sourceOrderComparator()
+	{
+		final BasicViewerState state = new BasicViewerState();
+		final List< SourceAndConverter< ? > > expected = new ArrayList<>();
+		for ( int i = 0; i < 10; ++i )
+		{
+			final SourceAndConverter< ? > source = createSource();
+			state.addSource( source );
+			expected.add( source );
+		}
+		final List< SourceAndConverter< ? > > actual = new ArrayList<>( expected );
+		Collections.shuffle( actual );
+
+		actual.sort( state.sourceOrder() );
+
+		Assert.assertEquals( expected, actual );
+	}
+
+	@Test
+	public void sourceIndices()
+	{
+		final BasicViewerState state = new BasicViewerState();
+		final SourceAndConverter< ? > s0 = createSource();
+		final SourceAndConverter< ? > s1 = createSource();
+		final SourceAndConverter< ? > s2 = createSource();
+
+		state.addSource( s0 );
+		state.addSource( s1 );
+		state.addSource( s2 );
+		state.removeSource( s1 );
+
+		Assert.assertEquals( state.getSources().indexOf( s0 ), 0 );
+		Assert.assertEquals( state.getSources().indexOf( s2 ), 1 );
+		Assert.assertFalse( state.containsSource( s1 ) );
+	}
+
+	@Test
+	public void groupIndices()
+	{
+		final BasicViewerState state = new BasicViewerState();
+		final SourceGroup g0 = new SourceGroup();
+		final SourceGroup g1 = new SourceGroup();
+		final SourceGroup g2 = new SourceGroup();
+
+		state.addGroup( g0 );
+		state.addGroup( g1 );
+		state.addGroup( g2 );
+		state.removeGroup( g1 );
+
+		Assert.assertEquals( state.getGroups().indexOf( g0 ), 0 );
+		Assert.assertEquals( state.getGroups().indexOf( g2 ), 1 );
+		Assert.assertFalse( state.containsGroup( g1 ) );
+	}
+
+	@Test
+	public void addSourceEvents()
+	{
+		final BasicViewerState state = new BasicViewerState();
+
+		final ReceiveEvents r = new ReceiveEvents( NUM_SOURCES_CHANGED, CURRENT_SOURCE_CHANGED, VISIBILITY_CHANGED );
+		state.changeListeners().add( r );
+
+		state.addSource( createSource() );
+
+		Assert.assertTrue( r.allReceivedExclusively() );
+	}
+
+	@Test
+	public void removeSourceEvents()
+	{
+		final BasicViewerState state = new BasicViewerState();
+		final SourceAndConverter< ? > s0 = createSource();
+		state.addSource( s0 );
+
+		final ReceiveEvents r = new ReceiveEvents( NUM_SOURCES_CHANGED, CURRENT_SOURCE_CHANGED, VISIBILITY_CHANGED );
+		state.changeListeners().add( r );
+
+		state.removeSource( s0 );
+
+		Assert.assertTrue( r.allReceivedExclusively() );
+	}
+
+	@Test
+	public void addGroupEvents()
+	{
+		final BasicViewerState state = new BasicViewerState();
+		state.addSource( createSource() );
+
+		final ReceiveEvents r = new ReceiveEvents( NUM_GROUPS_CHANGED, CURRENT_GROUP_CHANGED );
+		state.changeListeners().add( r );
+
+		state.addGroup( new SourceGroup() );
+
+		Assert.assertTrue( r.allReceivedExclusively() );
+	}
+
+	@Test
+	public void interpolationEvents1()
+	{
+		final BasicViewerState state = new BasicViewerState();
+		state.setInterpolation( NEARESTNEIGHBOR );
+
+		final ReceiveEvents r = new ReceiveEvents( INTERPOLATION_CHANGED );
+		state.changeListeners().add( r );
+
+		state.setInterpolation( NLINEAR );
+
+		Assert.assertTrue( r.allReceivedExclusively() );
+	}
+
+	@Test
+	public void displayModeEvents1()
+	{
+		final BasicViewerState state = new BasicViewerState();
+		final SourceAndConverter< ? > s0 = createSource();
+		final SourceAndConverter< ? > s1 = createSource();
+		state.addSource( s0 );
+		state.addSource( s1 );
+		state.setSourceActive( s0, true );
+		state.setSourceActive( s1, false );
+		state.setDisplayMode( SINGLE );
+
+		final ReceiveEvents r = new ReceiveEvents( DISPLAY_MODE_CHANGED );
+		state.changeListeners().add( r );
+
+		state.setDisplayMode( FUSED );
+
+		Assert.assertTrue( r.allReceivedExclusively() );
+	}
+
+	@Test
+	public void displayModeEvents2()
+	{
+		final BasicViewerState state = new BasicViewerState();
+		final SourceAndConverter< ? > s0 = createSource();
+		final SourceAndConverter< ? > s1 = createSource();
+		state.addSource( s0 );
+		state.addSource( s1 );
+		state.setSourceActive( s0, true );
+		state.setSourceActive( s1, true );
+		state.setDisplayMode( SINGLE );
+
+		final ReceiveEvents r = new ReceiveEvents( DISPLAY_MODE_CHANGED, VISIBILITY_CHANGED );
+		state.changeListeners().add( r );
+
+		state.setDisplayMode( FUSED );
+
+		Assert.assertTrue( r.allReceivedExclusively() );
+	}
+
+	// -- helpers --
+
+	static class ReceiveEvents implements ViewerStateChangeListener
+	{
+		private final ViewerStateChange[] changes;
+
+		private final List< ViewerStateChange > received = new ArrayList<>();
+
+		public ReceiveEvents( final ViewerStateChange... changes )
+		{
+			this.changes = changes;
+		}
+
+		@Override
+		public void viewerStateChanged( final ViewerStateChange change )
+		{
+			received.add( change );
+		}
+
+		/**
+		 * Check whether all of expected events were received at least once
+		 */
+		public boolean allReceived()
+		{
+			final Set< ViewerStateChange > expected = new HashSet<>();
+			expected.addAll( Arrays.asList( changes ) );
+			expected.removeAll( received );
+			return expected.isEmpty();
+		}
+
+		/**
+		 * Check whether all of expected events were received at least once, and no other event was received
+		 */
+		public boolean allReceivedExclusively()
+		{
+			final Set< ViewerStateChange > expected = new HashSet<>();
+			expected.addAll( Arrays.asList( changes ) );
+			expected.removeAll( received );
+			final Set< ViewerStateChange > additional = new HashSet<>();
+			additional.addAll( received );
+			additional.removeAll( Arrays.asList( changes ) );
+			final boolean success = expected.isEmpty() && additional.isEmpty();
+			if ( !success )
+			{
+				if ( !expected.isEmpty() )
+					System.out.println( "expected, but not received: " + expected );
+				if ( !additional.isEmpty() )
+					System.out.println( "received, but not expected: " + additional );
+			}
+			return success;
+		}
+	}
+
+	static class TestSource implements Source< Void >
+	{
+		private static final AtomicInteger id = new AtomicInteger();
+
+		private static String nextName()
+		{
+			return "source(" + id.getAndIncrement() + ")";
+		}
+
+		private final String name;
+
+		private final IntPredicate isPresent;
+
+		public TestSource()
+		{
+			this( nextName(), i -> false );
+		}
+
+		public TestSource( final String name )
+		{
+			this( name, i -> false );
+		}
+
+		public TestSource( final IntPredicate isPresent )
+		{
+			this( nextName(), isPresent );
+		}
+
+		public TestSource( final String name, final IntPredicate isPresent )
+		{
+			this.name = name;
+			this.isPresent = isPresent;
+		}
+
+		@Override
+		public Void getType()
+		{
+			return null;
+		}
+
+		@Override
+		public String getName()
+		{
+			return name;
+		}
+
+		@Override
+		public VoxelDimensions getVoxelDimensions()
+		{
+			return null;
+		}
+
+		@Override
+		public int getNumMipmapLevels()
+		{
+			return 1;
+		}
+
+		@Override
+		public boolean isPresent( final int t )
+		{
+			return isPresent.test( t );
+		}
+
+		@Override
+		public RandomAccessibleInterval< Void > getSource( final int t, final int level )
+		{
+			return null;
+		}
+
+		@Override
+		public RealRandomAccessible< Void > getInterpolatedSource( final int t, final int level, final Interpolation method )
+		{
+			return null;
+		}
+
+		@Override
+		public void getSourceTransform( final int t, final int level, final AffineTransform3D transform )
+		{
+			transform.identity();
+		}
+	}
+
+	static SourceAndConverter< ? > createSource()
+	{
+		return new SourceAndConverter<>( new TestSource(), null );
+	}
+}
diff --git a/src/test/java/bdv/viewer/InterpolationTest.java b/src/test/java/bdv/viewer/InterpolationTest.java
index f5f7a8e50c5e800dca94a02e599c0d94fdee434d..8e66b504ecd1d346958ca2bff58fcb0c331ef966 100644
--- a/src/test/java/bdv/viewer/InterpolationTest.java
+++ b/src/test/java/bdv/viewer/InterpolationTest.java
@@ -1,3 +1,31 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
 package bdv.viewer;
 
 import org.junit.Assert;