This is an automated email from the ASF dual-hosted git repository.

desruisseaux pushed a commit to branch geoapi-4.0
in repository https://gitbox.apache.org/repos/asf/sis.git


The following commit(s) were added to refs/heads/geoapi-4.0 by this push:
     new 6dc984a9b7 Allow to move simultaneously in all canvases shown in the 
same window. Contains a bug fix for handling retroaction (moving A moves B 
which moves A).
6dc984a9b7 is described below

commit 6dc984a9b79a267e41669c9ac5089a57c379944b
Author: Martin Desruisseaux <[email protected]>
AuthorDate: Fri Apr 10 13:00:53 2026 +0200

    Allow to move simultaneously in all canvases shown in the same window.
    Contains a bug fix for handling retroaction (moving A moves B which moves 
A).
---
 .../main/org/apache/sis/portrayal/Canvas.java      |  10 +-
 .../org/apache/sis/portrayal/CanvasFollower.java   |  96 +++++++++------
 .../org/apache/sis/portrayal/FollowContext.java    | 135 +++++++++++++++++++++
 .../main/org/apache/sis/portrayal/Observable.java  |  19 ++-
 .../org/apache/sis/portrayal/PlanarCanvas.java     |  10 +-
 .../apache/sis/portrayal/TransformChangeEvent.java |  21 +++-
 .../org/apache/sis/portrayal/package-info.java     |   8 +-
 .../main/org/apache/sis/gui/map/MultiCanvas.java   |  36 +++++-
 8 files changed, 277 insertions(+), 58 deletions(-)

diff --git 
a/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/Canvas.java
 
b/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/Canvas.java
index f81facfc8e..ea54822a9c 100644
--- 
a/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/Canvas.java
+++ 
b/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/Canvas.java
@@ -139,10 +139,12 @@ import 
org.opengis.coordinate.MismatchedCoordinateMetadataException;
  * The zoom level is given indirectly by the {@link #getObjectiveToDisplay()} 
transform.
  * The display device may have a wraparound axis, for example in the spherical 
coordinate system of a planetarium.
  *
- * <h2>Multi-threading</h2>
- * {@code Canvas} is not thread-safe. Synchronization, if desired, must be 
done by the caller.
- * Another common strategy is to interact with {@code Canvas} from a single 
thread,
- * for example the Swing or JavaFX event queue.
+ * <h2>Thread safety</h2>
+ * {@code Canvas} is not thread-safe.
+ * A single thread should be used for interactions with all instances of 
{@code Canvas}
+ * that may be referencing each other through {@linkplain 
#addPropertyChangeListener listeners}.
+ * External synchronization is generally not sufficient because listeners may 
create a graph of canvases,
+ * and it is difficult to ensure that a lock is kept during all the graph 
traversal.
  *
  * @author  Johann Sorel (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
diff --git 
a/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/CanvasFollower.java
 
b/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/CanvasFollower.java
index 0c06d54a41..443acaba45 100644
--- 
a/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/CanvasFollower.java
+++ 
b/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/CanvasFollower.java
@@ -53,8 +53,9 @@ import 
org.apache.sis.referencing.operation.transform.MathTransforms;
  * changes in {@linkplain #source} are applied on {@linkplain #target}, but 
not the converse.
  *
  * <h2>Multi-threading</h2>
- * This class is <strong>not</strong> thread-safe.
- * All events should be processed in the same thread.
+ * {@code CanvasFollower} is not thread-safe. The {@linkplain #source} and 
{@linkplain #target} canvases,
+ * together with all other canvases that are connected in the same graph of 
{@link PlanarCanvas} objects
+ * through any other {@code CanvasFollower} instances, shall be accessed in 
the same thread.
  *
  * @author  Martin Desruisseaux (Geomatys)
  * @version 1.7
@@ -126,10 +127,18 @@ public class CanvasFollower implements 
PropertyChangeListener, Disposable {
     private enum Status {VALID, OUTDATED, UNKNOWN, ERROR}
 
     /**
-     * Whether a change is in progress. This is for avoiding never-ending loop
-     * if a bidirectional mapping or a cycle exists (A → B → C → A).
+     * Contextual information about an "objective to display" transform change 
which is progress.
+     * The contextual information is necessary in particular for avoiding 
never-ending recursive
+     * loops if a chain of {@link CanvasFollower} result in a cyclic graph of 
{@code PlanarCanvas}.
+     *
+     * <h4>Design note</h4>
+     * We cannot store this information as a field in {@link 
TransformChangeEvent} because in a chain
+     * such as <var>A</var> → <var>B</var> → <var>C</var>, the {@code 
TransformChangeEvent} instance
+     * is not the same between <var>A</var> and <var>B</var> than between 
<var>B</var> and <var>C</var>.
+     * The use of thread local requires that all events are managed from a 
unique thread.
+     * This advice is documented in the package and {@link Canvas} Javadoc.
      */
-    private boolean changing;
+    private static final ThreadLocal<FollowContext> CONTEXT = 
ThreadLocal.withInitial(FollowContext::new);
 
     /**
      * Creates a new listener for synchronizing "objective to display" 
transform changes
@@ -349,42 +358,26 @@ public class CanvasFollower implements 
PropertyChangeListener, Disposable {
      */
     @Override
     public void propertyChange(final PropertyChangeEvent event) {
-        if (!changing && event instanceof TransformChangeEvent) try {
+        if (event instanceof TransformChangeEvent) {
             final var te = (TransformChangeEvent) event;
             displayTransformStatus = Status.OUTDATED;
-            changing = true;
-            if (te.isSameSource(source)) {
+            if (te.isSameSource(target)) {
+                transformedTarget(te);
+            } else if (te.isSameSource(source)) {
                 transformedSource(te);
                 if (!disabled && filter(te)) {
-                    if (followRealWorld && 
findObjectiveTransform("propertyChange")) {
-                        AffineTransform before = 
te.getObjectiveChange2D().orElse(null);
-                        if (before != null) try {
-                            /*
-                             * Converts a change from units of the source CRS 
to units of the target CRS.
-                             * If that change cannot be computed, fallback on 
a change in display units.
-                             * The POI may be null, but this is okay if the 
transform is linear.
-                             */
-                            if (objectiveTransform != null) {
-                                DirectPosition poi = getSourceObjectivePOI();
-                                AffineTransform t = 
AffineTransforms2D.castOrCopy(MathTransforms.tangent(objectiveTransform, poi));
-                                AffineTransform c = t.createInverse();
-                                c.preConcatenate(before);
-                                c.preConcatenate(t);
-                                before = c;
-                            }
-                            transformObjectiveCoordinates(te, before);
-                            return;
-                        } catch (NullPointerException | TransformException | 
NoninvertibleTransformException e) {
-                            canNotCompute("propertyChange", e);
-                        }
+                    final FollowContext context = CONTEXT.get();
+                    if (context.isPropagating(this)) {
+                        context.propagateOrDefer(this, te);
+                    } else try {
+                        te.deferredListeners = context;
+                        context.propagateOrDefer(this, te);
+                    } finally {
+                        te.deferredListeners = null;
+                        context.clear();
                     }
-                    te.getDisplayChange2D().ifPresent((after) -> 
transformDisplayCoordinates(te, after));
                 }
-            } else if (te.isSameSource(target)) {
-                transformedTarget(te);
             }
-        } finally {
-            changing = false;
         } else if 
(PlanarCanvas.OBJECTIVE_CRS_PROPERTY.equals(event.getPropertyName())) {
             displayTransform         = null;
             objectiveTransform       = null;
@@ -393,6 +386,39 @@ public class CanvasFollower implements 
PropertyChangeListener, Disposable {
         }
     }
 
+    /**
+     * Applies to the {@linkplain #target} canvas the change described by the 
given event.
+     * This method shall be invoked only if the change has not already been 
applied on the
+     * target canvas.
+     *
+     * @param  event  the change to apply on the {@linkplain #target} canvas.
+     */
+    final void propagate(final TransformChangeEvent event) {
+        if (followRealWorld && findObjectiveTransform("propertyChange")) {
+            AffineTransform before = event.getObjectiveChange2D().orElse(null);
+            if (before != null) try {
+                /*
+                 * Converts a change from units of the source CRS to units of 
the target CRS.
+                 * If that change cannot be computed, fallback on a change in 
display units.
+                 * The POI may be null, but this is okay if the transform is 
linear.
+                 */
+                if (objectiveTransform != null) {
+                    DirectPosition poi = getSourceObjectivePOI();
+                    AffineTransform t = 
AffineTransforms2D.castOrCopy(MathTransforms.tangent(objectiveTransform, poi));
+                    AffineTransform c = t.createInverse();
+                    c.preConcatenate(before);
+                    c.preConcatenate(t);
+                    before = c;
+                }
+                transformObjectiveCoordinates(event, before);
+                return;
+            } catch (NullPointerException | TransformException | 
NoninvertibleTransformException e) {
+                canNotCompute("propertyChange", e);
+            }
+        }
+        event.getDisplayChange2D().ifPresent((after) -> 
transformDisplayCoordinates(event, after));
+    }
+
     /**
      * Returns {@code true} if this listener should replicate the following 
changes on the target canvas.
      * The default implementation returns {@code true} if the transform reason 
is
@@ -400,7 +426,7 @@ public class CanvasFollower implements 
PropertyChangeListener, Disposable {
      * {@link TransformChangeEvent.Reason#DISPLAY_NAVIGATION}.
      *
      * @param  event  a transform change event that occurred on the 
{@linkplain #source} canvas.
-     * @return  whether to replicate that change on the {@linkplain #target} 
canvas.
+     * @return whether to replicate that change on the {@linkplain #target} 
canvas.
      */
     protected boolean filter(final TransformChangeEvent event) {
         return event.getReason().isNavigation();
diff --git 
a/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/FollowContext.java
 
b/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/FollowContext.java
new file mode 100644
index 0000000000..971380dcff
--- /dev/null
+++ 
b/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/FollowContext.java
@@ -0,0 +1,135 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.portrayal;
+
+import java.util.Map;
+import java.util.Queue;
+import java.util.ArrayDeque;
+import java.util.IdentityHashMap;
+
+
+/**
+ * Contextual information about an "objective to display" transform change 
which is in progress.
+ * We use one instance per thread on the assumption that events are processed 
in the same thread,
+ * at least between {@link PlanarCanvas} instances that are connected in the 
same graph.
+ * This condition is documented in the {@link CanvasFollower} class Javadoc.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ *
+ * @see CanvasFollower#CONTEXT
+ */
+final class FollowContext {
+    /**
+     * Whether to add {@link CanvasFollower} instances to the {@link 
#deferred} queue
+     * instead of executing their {@code propagate(…)} method.
+     *
+     * @see CanvasFollower#propagate(TransformChangeEvent)
+     */
+    private boolean propagateLater;
+
+    /**
+     * Listeners for which the call to {@code propagate(…)} has been deferred 
to a later time.
+     * This is needed when we have two or more {@link CanvasFollower} 
instances registered on
+     * the same source canvas but for different target canvases.
+     *
+     * <h4>Use case</h4>
+     * Consider a case where a change in canvas <var>A</var> is propagated to 
canvas <var>B</var>
+     * which in turn propagates its own change to canvas <var>C</var>. If 
canvas <var>A</var> has
+     * another {@code CanvasFollower} propagating the same change directly to 
<var>C</var>, it is
+     * better to give precedence to the latter because it is more direct (it 
avoids to propagate
+     * a transformation of another transformation). But because of the order 
in which a tree of
+     * listeners is executed, we need a mechanism if which the execution of a 
branch is deferred.
+     *
+     * @see CanvasFollower#propagate(TransformChangeEvent)
+     */
+    private final Queue<CanvasFollower> deferred;
+
+    /**
+     * Canvases in which a change of "objective to display" transform has 
already been propagated.
+     * This is used for avoiding never-ending loops if two or more instances 
of {@link CanvasFollower}
+     * result in a cyclic graph of {@code PlanarCanvas}.
+     */
+    private final Map<PlanarCanvas, Boolean> propagated;
+
+    /**
+     * Creates an empty context.
+     */
+    FollowContext() {
+        deferred   = new ArrayDeque<>(4);       // There is usually not many 
instances.
+        propagated = new IdentityHashMap<>(4);
+    }
+
+    /**
+     * Resets this {@code FollowContext} to the same state as after 
construction.
+     */
+    final void clear() {
+        propagateLater = false;
+        deferred.clear();
+        propagated.clear();
+    }
+
+    /**
+     * Returns whether a {@code TransformChangeEvent} is already in process of 
being propagated.
+     * A return value of {@code true} means that the caller is handling events 
that were fired as
+     * a consequence of the original event, or are listeners notified after 
the first listener.
+     */
+    final boolean isPropagating(final CanvasFollower follower) {
+        if (propagated.isEmpty()) {
+            propagated.put(follower.source, Boolean.TRUE);
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * Executes {@code follower.propagate(…)} immediately or adds it to a 
queue of methods to be invoked later.
+     * The purpose is to execute {@code propagate(…)} in a different order 
than the usual tree traversal order.
+     * We want all siblings to be executed before to traverse the children.
+     *
+     * <p>This method does nothing if the target canvas has already been 
notified.</p>
+     *
+     * @param  follower  the follower for which to execute or defer the call 
to {@code propagate(…)}.
+     * @param  event     the event to propagate if it needs to be done 
immediately.
+     */
+    final void propagateOrDefer(final CanvasFollower follower, final 
TransformChangeEvent event) {
+        if (propagated.put(follower.target, Boolean.FALSE) == null) {
+            if (propagateLater) {
+                deferred.add(follower);
+            } else {
+                propagateLater = true;
+                follower.propagate(event);
+                propagateLater = false;
+            }
+        }
+    }
+
+    /**
+     * Executes all {@code follower.propagate(…)} calls that were deferred.
+     * This method should be invoked after all siblings have been processed.
+     * Target canvases that have already been processed are ignored.
+     *
+     * @param  event  the event to propagate.
+     */
+    final void executeDeferred(final TransformChangeEvent event) {
+        CanvasFollower follower;
+        while ((follower = deferred.poll()) != null) {
+            if (propagated.put(follower.target, Boolean.FALSE) == null) {
+                follower.propagate(event);
+            }
+        }
+    }
+}
diff --git 
a/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/Observable.java
 
b/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/Observable.java
index 8554517c21..5cec043ef2 100644
--- 
a/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/Observable.java
+++ 
b/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/Observable.java
@@ -30,7 +30,7 @@ import org.apache.sis.util.ArraysExt;
  * Parent class of all objects for which it is possible to register listeners.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.5
+ * @version 1.7
  * @since   1.5
  */
 public abstract class Observable {
@@ -45,7 +45,7 @@ public abstract class Observable {
      * @see #addPropertyChangeListener(String, PropertyChangeListener)
      * @see #removePropertyChangeListener(String, PropertyChangeListener)
      */
-    private Map<String,PropertyChangeListener[]> listeners;
+    private Map<String, PropertyChangeListener[]> listeners;
 
     /**
      * Creates a new instance.
@@ -88,7 +88,7 @@ public abstract class Observable {
         ArgumentChecks.ensureNonNull("listener", listener);
         if (listeners != null) {
             listeners.computeIfPresent(propertyName, (key, oldList) -> {
-                for (int i=oldList.length; --i >= 0;) {
+                for (int i = oldList.length; --i >= 0;) {
                     if (oldList[i] == listener) {
                         if (oldList.length != 1) {
                             return ArraysExt.remove(oldList, i, 1);
@@ -129,7 +129,7 @@ public abstract class Observable {
         if (listeners != null) {
             final PropertyChangeListener[] list = listeners.get(propertyName);
             if (list != null) {
-                final PropertyChangeEvent event = new 
PropertyChangeEvent(this, propertyName, oldValue, newValue);
+                final var event = new PropertyChangeEvent(this, propertyName, 
oldValue, newValue);
                 for (final PropertyChangeListener listener : list) {
                     listener.propertyChange(event);
                 }
@@ -154,6 +154,17 @@ public abstract class Observable {
                 for (final PropertyChangeListener listener : list) {
                     listener.propertyChange(event);
                 }
+                /*
+                 * For separation of concerns, following should be managed in 
`CanvasFollower`.
+                 * But it is difficult to ensure there that it is executed 
after all listeners.
+                 */
+                if (event instanceof TransformChangeEvent) {
+                    final var te = (TransformChangeEvent) event;
+                    final FollowContext deferredListeners = 
te.deferredListeners;
+                    if (deferredListeners != null) {
+                        deferredListeners.executeDeferred(te);
+                    }
+                }
             }
         }
     }
diff --git 
a/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/PlanarCanvas.java
 
b/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/PlanarCanvas.java
index ef7ea9a38b..6c1a4e4744 100644
--- 
a/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/PlanarCanvas.java
+++ 
b/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/PlanarCanvas.java
@@ -34,10 +34,12 @@ import 
org.apache.sis.referencing.internal.shared.AffineTransform2D;
  * A canvas for two-dimensional display device using a Cartesian coordinate 
system.
  * Data are reduced to a two-dimensional slice before to be displayed.
  *
- * <h2>Multi-threading</h2>
- * {@code PlanarCanvas} is not thread-safe. Synchronization, if desired, must 
be done by the caller.
- * Another common strategy is to interact with {@code PlanarCanvas} from a 
single thread,
- * for example the Swing or JavaFX event queue.
+ * <h2>Thread safety</h2>
+ * {@code PlanarCanvas} is not thread-safe.
+ * A single thread should be used for interactions with all instances of 
{@code PlanarCanvas} that are
+ * linked together by {@link CanvasFollower} or other {@linkplain 
#addPropertyChangeListener listeners}.
+ * External synchronization is generally not sufficient because listeners may 
create a graph of canvases,
+ * and it is difficult to ensure that a lock is kept during all the graph 
traversal.
  *
  * @author  Johann Sorel (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
diff --git 
a/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/TransformChangeEvent.java
 
b/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/TransformChangeEvent.java
index 444da8c9bc..891a31077d 100644
--- 
a/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/TransformChangeEvent.java
+++ 
b/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/TransformChangeEvent.java
@@ -37,12 +37,15 @@ import org.apache.sis.util.logging.Logging;
  * are instances of this class.
  * This specialization provides methods for computing the difference between 
the old and new state.
  *
+ * <p>Instances of {@code TransformChangeEvent} should be short-lived. They 
exist the time needed
+ * for processing an event, but should not be retained for a long time and 
should not be reused.</p>
+ *
  * <h2>Multi-threading</h2>
- * This class is <strong>not</strong> thread-safe.
- * All listeners should process this event in the same thread.
+ * {@code TransformChangeEvent} is not thread-safe. All listeners shall 
process this event in the
+ * thread that {@linkplain Observable#firePropertyChange(PropertyChangeEvent) 
fired} this event.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.3
+ * @version 1.7
  *
  * @see Canvas#OBJECTIVE_TO_DISPLAY_PROPERTY
  *
@@ -144,6 +147,18 @@ public class TransformChangeEvent extends 
PropertyChangeEvent {
      */
     private transient Exception error;
 
+    /**
+     * Listeners that have not been fully notified of this event.
+     * Part of the execution of these listeners were deferred.
+     * This is for internal usage by {@link CanvasFollower}.
+     *
+     * <h4>Design note</h4>
+     * For separation of concerns, it would be better to manage deferred 
propagation in {@link CanvasFollower}.
+     * But we want to apply deferred changes after {@link 
Observable#firePropertyChange(PropertyChangeEvent)}
+     * finished to loop over all listeners, while a {@link CanvasFollower} 
instance is only one of those listeners.
+     */
+    transient FollowContext deferredListeners;
+
     /**
      * Creates a new event for a change of the "objective to display" property.
      * The old and new transforms should not be null, except on initialization 
or for lazy computation:
diff --git 
a/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/package-info.java
 
b/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/package-info.java
index 9a6c67d79e..8722a340ce 100644
--- 
a/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/package-info.java
+++ 
b/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/package-info.java
@@ -20,9 +20,13 @@
  * Symbology and map representations, together with a rendering engine for 
display.
  * This package is currently in early draft stage.
  *
- * <h2>Synchronization</h2>
+ * <h2>Thread safety</h2>
  * Unless otherwise specified, classes in this package are not thread-safe.
- * Synchronization, if desired, must be done by the caller.
+ * A single thread should be used for interactions with all instances of
+ * {@link org.apache.sis.portrayal.Canvas} that are linked together by
+ * {@link org.apache.sis.portrayal.CanvasFollower} or other listeners.
+ * External synchronization is generally not sufficient because listeners may 
create a graph of canvases,
+ * and it is difficult to ensure that a lock is kept during all the graph 
traversal.
  *
  * @author  Johann Sorel (Geomatys)
  * @version 1.7
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MultiCanvas.java 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MultiCanvas.java
index 924c229236..dcaf62a4bd 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MultiCanvas.java
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MultiCanvas.java
@@ -27,11 +27,14 @@ import java.util.NoSuchElementException;
 import java.util.Objects;
 import java.util.Set;
 import javafx.application.Platform;
-import javafx.beans.InvalidationListener;
 import javafx.beans.Observable;
+import javafx.beans.InvalidationListener;
+import javafx.beans.value.ChangeListener;
+import javafx.beans.value.ObservableValue;
 import javafx.scene.Node;
 import javafx.scene.control.Label;
 import javafx.scene.control.Button;
+import javafx.scene.control.ToggleButton;
 import javafx.scene.input.MouseEvent;
 import javafx.scene.layout.BorderPane;
 import javafx.scene.layout.Region;
@@ -49,6 +52,7 @@ import org.apache.sis.gui.Widget;
 import org.apache.sis.gui.coverage.CoverageCanvas;
 import org.apache.sis.gui.internal.BackgroundThreads;
 import org.apache.sis.gui.internal.DataStoreOpener;
+import org.apache.sis.gui.internal.FontGIS;
 import static org.apache.sis.gui.internal.LogHandler.LOGGER;
 import org.apache.sis.gui.referencing.RecentReferenceSystems;
 import org.apache.sis.io.TableAppender;
@@ -119,7 +123,7 @@ final class MultiCanvas extends Widget implements 
Observable {
     /**
      * Controls associated to each map canvas.
      */
-    private static final class Controls {
+    private static final class Controls implements ChangeListener<Boolean> {
         /**
          * The title of the associated map canvas. This label is not 
necessarily shown.
          * If the enclosing {@link MultiCanvas} contains only one {@link 
MapCanvas},
@@ -138,6 +142,8 @@ final class MultiCanvas extends Widget implements 
Observable {
 
         /**
          * Listeners of mouse displacements and navigation actions such as 
zooms and pans.
+         * The {@link GestureFollower#source} canvas of all elements shall be 
the {@code canvas} argument
+         * given to the constructor.
          */
         final List<GestureFollower> followers;
 
@@ -199,6 +205,18 @@ final class MultiCanvas extends Widget implements 
Observable {
             followers.forEach(GestureFollower::dispose);
             followers.clear();
         }
+
+        /**
+         * Invoked when the user pressed the button for enabling or disabling 
the propagation of
+         * navigation events from the canvas associated to this {@code 
Controls} to other canvases.
+         */
+        @Override
+        public void changed(ObservableValue<? extends Boolean> property, 
Boolean oldValue, Boolean newValue) {
+            final boolean enabled = newValue;   // Unboxing.
+            for (final GestureFollower follower : followers) {
+                follower.transformEnabled.set(enabled);
+            }
+        }
     }
 
     /**
@@ -333,10 +351,10 @@ final class MultiCanvas extends Widget implements 
Observable {
         switch (children.size()) {
             case 0:  break;
             case 1:  var previous = (Region) children.removeLast();
-                     previous = addTitleBar(previous, 
getControls(previous).title);
+                     previous = addTitleBar(previous, getControls(previous));
                      children.add(previous);
                      // Fall through
-            default: canvasView = addTitleBar(canvasView, controls.title);
+            default: canvasView = addTitleBar(canvasView, controls);
         }
         /*
          * Add listeners for replicating navigation events of `canvas` into 
all other visible canvases,
@@ -410,14 +428,20 @@ final class MultiCanvas extends Widget implements 
Observable {
      * Wraps the given {@code MapCanvas} view into a pane with a title.
      *
      * @param  canvasView  the {@link MapCanvas} view for which to add a title 
bar.
+     * @param  controls    value of {@code canvasPool.get(canvas)} (not 
necessarily obtained by that call).
      * @return a wrapper of {@code canvasView} with the addition of a title 
bar.
      */
-    private BorderPane addTitleBar(final Region canvasView, final Label title) 
{
+    private BorderPane addTitleBar(final Region canvasView, final Controls 
controls) {
+        final Label title = controls.title;
         final var close = new Button("❌");
         close.setOnAction((event) -> closeCanvasView(canvasView));
+        final var pin = new ToggleButton();
+        pin.selectedProperty().addListener(controls);
+        FontGIS.setGlyph(pin, FontGIS.Code.MOVE, "⮀", -1, -1);
+        HBox.setHgrow(pin,   Priority.NEVER);
         HBox.setHgrow(title, Priority.ALWAYS);
         HBox.setHgrow(close, Priority.NEVER);
-        final var bar = new HBox(title, close);
+        final var bar = new HBox(pin, title, close);
         bar.setAlignment(Pos.CENTER);
         title.setAlignment(Pos.CENTER);
         title.setMaxWidth(Double.MAX_VALUE);

Reply via email to