diff --git a/eclipse-scout-core/src/desktop/Desktop.ts b/eclipse-scout-core/src/desktop/Desktop.ts index 16a809c417b..9a646d9ce32 100644 --- a/eclipse-scout-core/src/desktop/Desktop.ts +++ b/eclipse-scout-core/src/desktop/Desktop.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2010, 2024 BSI Business Systems Integration AG + * Copyright (c) 2010, 2026 BSI Business Systems Integration AG * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -11,7 +11,7 @@ import { AbstractLayout, Action, arrays, BenchColumnLayoutData, BusyIndicatorOptions, BusySupport, cookies, DeferredGlassPaneTarget, DesktopBench, DesktopBenchViewActivateEvent, DesktopEventMap, DesktopFormController, DesktopHeader, DesktopLayout, DesktopModel, DesktopNavigation, DesktopNotification, Device, DisableBrowserF5ReloadKeyStroke, DisableBrowserTabSwitchingKeyStroke, DisplayParent, DisplayViewId, EnumObject, Event, EventEmitter, EventHandler, FileChooser, FileChooserController, Form, GlassPaneTarget, HtmlComponent, HtmlEnvironment, InitModelOf, KeyStrokeContext, Menu, MessageBox, MessageBoxController, NativeNotificationVisibility, ObjectOrChildModel, ObjectOrModel, objects, - OfflineDesktopNotification, OpenUriHandler, Outline, OutlineContent, OutlineViewButton, Popup, ReloadPageOptions, ResponsiveHandler, scout, SimpleTabArea, SimpleTabBox, Splitter, SplitterMoveEndEvent, SplitterMoveEvent, + OfflineDesktopNotification, OpenUriHandler, Outline, OutlineContent, OutlineViewButton, Popup, PopupManager, ReloadPageOptions, ResponsiveHandler, scout, SimpleTabArea, SimpleTabBox, Splitter, SplitterMoveEndEvent, SplitterMoveEvent, SplitterPositionChangeEvent, strings, styles, Tooltip, Tree, TreeDisplayStyle, UnsavedFormChangesForm, URL, ViewButton, webstorage, Widget, widgets } from '../index'; import $ from 'jquery'; @@ -1861,6 +1861,17 @@ export class Desktop extends Widget implements DesktopModel, DisplayParent { }); } + repositionPopups() { + const popupManager = this.addOns.find(addOn => addOn instanceof PopupManager) as PopupManager; + if (!popupManager) { + return; + } + + for (const popup of popupManager.popups) { + popup.position(); + } + } + protected override _renderTrackFocus() { if (this.trackFocus) { // Use capture phase because FocusContext stops propagation diff --git a/eclipse-scout-core/src/tile/TileGridLayout.ts b/eclipse-scout-core/src/tile/TileGridLayout.ts index e4d4f074f15..d34a4aea7e4 100644 --- a/eclipse-scout-core/src/tile/TileGridLayout.ts +++ b/eclipse-scout-core/src/tile/TileGridLayout.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2010, 2024 BSI Business Systems Integration AG + * Copyright (c) 2010, 2026 BSI Business Systems Integration AG * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -250,6 +250,9 @@ export class TileGridLayout extends LogicalGridLayout { protected _onAnimationDone() { this._updateScrollbar(); this.widget.trigger('layoutAnimationDone'); + + // tiles may be the anchors of popups, reposition popups after all tiles are at their final position + this.widget.findDesktop()?.repositionPopups(); } protected _animateTileBounds(tile: Tile, fromBounds: Rectangle, bounds: Rectangle): JQuery.Promise { diff --git a/org.eclipse.scout.rt.client.test/src/test/java/org/eclipse/scout/rt/client/ui/popup/PopupTest.java b/org.eclipse.scout.rt.client.test/src/test/java/org/eclipse/scout/rt/client/ui/popup/PopupTest.java new file mode 100644 index 00000000000..f23df6d9aad --- /dev/null +++ b/org.eclipse.scout.rt.client.test/src/test/java/org/eclipse/scout/rt/client/ui/popup/PopupTest.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2010, 2026 BSI Business Systems Integration AG + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.scout.rt.client.ui.popup; + +import static org.junit.Assert.*; + +import org.eclipse.scout.rt.client.testenvironment.TestEnvironmentClientSession; +import org.eclipse.scout.rt.client.ui.desktop.IDesktop; +import org.eclipse.scout.rt.client.ui.form.fields.stringfield.AbstractStringField; +import org.eclipse.scout.rt.testing.client.runner.ClientTestRunner; +import org.eclipse.scout.rt.testing.client.runner.RunWithClientSession; +import org.eclipse.scout.rt.testing.platform.runner.RunWithSubject; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(ClientTestRunner.class) +@RunWithSubject("default") +@RunWithClientSession(TestEnvironmentClientSession.class) +public class PopupTest { + + @Test + public void testPopupDisposeOnAnchorDispose() { + + // dispose current anchor -> popup is closed + + MyPopup popup = new MyPopup(); + MyStringField anchor = new MyStringField(); + popup.setAnchor(anchor); + popup.open(); + + assertTrue(IDesktop.CURRENT.get().getAddOn(PopupManager.class).getPopups().contains(popup)); + assertFalse(popup.isDisposeDone()); + assertFalse(anchor.isDisposeDone()); + + anchor.dispose(); + + assertFalse(IDesktop.CURRENT.get().getAddOn(PopupManager.class).getPopups().contains(popup)); + assertTrue(popup.isDisposeDone()); + assertTrue(anchor.isDisposeDone()); + + // dispose former anchor -> popup is not closed + + popup = new MyPopup(); + anchor = new MyStringField(); + popup.setAnchor(anchor); + popup.open(); + + assertTrue(IDesktop.CURRENT.get().getAddOn(PopupManager.class).getPopups().contains(popup)); + assertFalse(popup.isDisposeDone()); + assertFalse(anchor.isDisposeDone()); + + popup.setAnchor(null); + anchor.dispose(); + + assertTrue(IDesktop.CURRENT.get().getAddOn(PopupManager.class).getPopups().contains(popup)); + assertFalse(popup.isDisposeDone()); + assertTrue(anchor.isDisposeDone()); + } + + protected static class MyStringField extends AbstractStringField { + } + + protected static class MyPopup extends AbstractPopup { + } +} diff --git a/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/popup/AbstractPopup.java b/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/popup/AbstractPopup.java index b6faead717b..3af074064d8 100644 --- a/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/popup/AbstractPopup.java +++ b/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/popup/AbstractPopup.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2010, 2023 BSI Business Systems Integration AG + * Copyright (c) 2010, 2026 BSI Business Systems Integration AG * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -9,6 +9,9 @@ */ package org.eclipse.scout.rt.client.ui.popup; +import java.beans.PropertyChangeEvent; +import java.beans.PropertyChangeListener; + import org.eclipse.scout.rt.client.ModelContextProxy; import org.eclipse.scout.rt.client.ModelContextProxy.ModelContext; import org.eclipse.scout.rt.client.ui.AbstractWidget; @@ -24,6 +27,7 @@ public abstract class AbstractPopup extends AbstractWidget implements IPopup { private IPopupUIFacade m_uiFacade; + private P_AnchorDisposeListener m_anchorDisposeListener; public AbstractPopup() { this(true); @@ -247,6 +251,15 @@ public void setVerticalSwitch(boolean verticalSwitch) { protected void initConfig() { super.initConfig(); m_uiFacade = BEANS.get(ModelContextProxy.class).newProxy(new P_UIFacade(), ModelContext.copyCurrent()); + m_anchorDisposeListener = createAnchorDisposeListener(); + addPropertyChangeListener(IPopup.PROP_ANCHOR, event -> { + if (event.getOldValue() instanceof IWidget) { + ((IWidget) event.getOldValue()).removePropertyChangeListener(IWidget.PROP_DISPOSE_DONE, getAnchorDisposeListener()); + } + if (event.getNewValue() instanceof IWidget) { + ((IWidget) event.getNewValue()).addPropertyChangeListener(IWidget.PROP_DISPOSE_DONE, getAnchorDisposeListener()); + } + }); setAnchor(getConfiguredAnchor()); setAnimateOpening(getConfiguredAnimateOpening()); @@ -291,4 +304,20 @@ public void close() { getPopupManager().close(this); dispose(); } + + protected P_AnchorDisposeListener getAnchorDisposeListener() { + return m_anchorDisposeListener; + } + + protected P_AnchorDisposeListener createAnchorDisposeListener() { + return new P_AnchorDisposeListener(); + } + + protected class P_AnchorDisposeListener implements PropertyChangeListener { + + @Override + public void propertyChange(PropertyChangeEvent event) { + close(); + } + } } diff --git a/org.eclipse.scout.rt.ui.html.test/src/test/java/org/eclipse/scout/rt/ui/html/json/popup/JsonPopupManagerTest.java b/org.eclipse.scout.rt.ui.html.test/src/test/java/org/eclipse/scout/rt/ui/html/json/popup/JsonPopupManagerTest.java new file mode 100644 index 00000000000..ebf107e3d13 --- /dev/null +++ b/org.eclipse.scout.rt.ui.html.test/src/test/java/org/eclipse/scout/rt/ui/html/json/popup/JsonPopupManagerTest.java @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2010, 2026 BSI Business Systems Integration AG + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.scout.rt.ui.html.json.popup; + +import static org.eclipse.scout.rt.platform.util.Assertions.assertInstance; +import static org.junit.Assert.*; + +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import org.eclipse.scout.rt.client.testenvironment.TestEnvironmentClientSession; +import org.eclipse.scout.rt.client.ui.form.fields.stringfield.AbstractStringField; +import org.eclipse.scout.rt.client.ui.popup.AbstractPopup; +import org.eclipse.scout.rt.client.ui.popup.PopupManager; +import org.eclipse.scout.rt.testing.client.runner.ClientTestRunner; +import org.eclipse.scout.rt.testing.client.runner.RunWithClientSession; +import org.eclipse.scout.rt.testing.platform.runner.RunWithSubject; +import org.eclipse.scout.rt.ui.html.json.IJsonAdapter; +import org.eclipse.scout.rt.ui.html.json.JsonAdapterUtility; +import org.eclipse.scout.rt.ui.html.json.JsonPropertyChangeEvent; +import org.eclipse.scout.rt.ui.html.json.fixtures.UiSessionMock; +import org.eclipse.scout.rt.ui.html.json.testing.JsonTestUtility; +import org.json.JSONArray; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(ClientTestRunner.class) +@RunWithSubject("default") +@RunWithClientSession(TestEnvironmentClientSession.class) +public class JsonPopupManagerTest { + + private UiSessionMock m_uiSession; + + @Before + public void setUp() { + m_uiSession = new UiSessionMock(); + JsonTestUtility.endRequest(m_uiSession); + } + + @After + public void tearDown() { + JsonTestUtility.endRequest(m_uiSession); + getPopupManager().getPopups().forEach(getPopupManager()::close); + } + + private PopupManager getPopupManager() { + return m_uiSession.getClientSession().getDesktop().getAddOn(PopupManager.class); + } + + @Test + public void testAnchorPropertyFilter() { + JsonPopupManager jsonPopupManager = m_uiSession.createJsonAdapter(getPopupManager(), m_uiSession.getRootJsonAdapter()); + + MyPopup popupWithoutAnchor = new MyPopup(); + + MyPopup popupWithAnchorWithoutJsonAdapter = new MyPopup(); + popupWithAnchorWithoutJsonAdapter.setAnchor(new MyStringField()); + + MyPopup popupWithAnchorWithJsonAdapter = new MyPopup(); + MyStringField anchor = new MyStringField(); + m_uiSession.createJsonAdapter(anchor, m_uiSession.getRootJsonAdapter()); + popupWithAnchorWithJsonAdapter.setAnchor(anchor); + + getPopupManager().open(popupWithoutAnchor); + getPopupManager().open(popupWithAnchorWithoutJsonAdapter); + getPopupManager().open(popupWithAnchorWithJsonAdapter); + + // property change event does not contain popups with anchors that do not have a json adapter + assertEquals(1, m_uiSession.currentJsonResponse().getEventList().size()); + JsonPropertyChangeEvent event = assertInstance(m_uiSession.currentJsonResponse().getEventList().get(0), JsonPropertyChangeEvent.class); + assertEquals("property", event.getType()); + assertEquals(jsonPopupManager.getId(), event.getTarget()); + assertEquals(1, event.getProperties().size()); + assertTrue(event.getProperties().containsKey(PopupManager.PROP_POPUPS)); + JSONArray jsonPopups = assertInstance(event.getProperties().get(PopupManager.PROP_POPUPS), JSONArray.class); + assertEquals(2, jsonPopups.length()); + assertEquals( + Set.of( + JsonAdapterUtility.findChildAdapter(jsonPopupManager, popupWithoutAnchor).getId(), + JsonAdapterUtility.findChildAdapter(jsonPopupManager, popupWithAnchorWithJsonAdapter).getId() + ), + IntStream.range(0, jsonPopups.length()) + .mapToObj(jsonPopups::getString) + .collect(Collectors.toSet()) + ); + } + + @Test + public void testAnchorChanges() { + JsonPopupManager jsonPopupManager = m_uiSession.createJsonAdapter(getPopupManager(), m_uiSession.getRootJsonAdapter()); + + // no anchor -> popup is part of property change event + + MyPopup popup = new MyPopup(); + getPopupManager().open(popup); + + assertEquals(1, m_uiSession.currentJsonResponse().getEventList().size()); + JsonPropertyChangeEvent event = assertInstance(m_uiSession.currentJsonResponse().getEventList().get(0), JsonPropertyChangeEvent.class); + assertEquals("property", event.getType()); + assertEquals(jsonPopupManager.getId(), event.getTarget()); + assertEquals(1, event.getProperties().size()); + assertTrue(event.getProperties().containsKey(PopupManager.PROP_POPUPS)); + JSONArray jsonPopups = assertInstance(event.getProperties().get(PopupManager.PROP_POPUPS), JSONArray.class); + assertEquals(1, jsonPopups.length()); + assertEquals(JsonAdapterUtility.findChildAdapter(jsonPopupManager, popup).getId(), jsonPopups.getString(0)); + + // anchor without json adapter -> popup is not part of property change event + + popup.setAnchor(new MyStringField()); + + assertEquals(1, m_uiSession.currentJsonResponse().getEventList().size()); + assertEquals(event, m_uiSession.currentJsonResponse().getEventList().get(0)); + jsonPopups = assertInstance(event.getProperties().get(PopupManager.PROP_POPUPS), JSONArray.class); + assertEquals(0, jsonPopups.length()); + + // anchor with json adapter -> popup is part of property change event + + MyStringField anchor = new MyStringField(); + m_uiSession.createJsonAdapter(anchor, m_uiSession.getRootJsonAdapter()); + popup.setAnchor(anchor); + + assertEquals(1, m_uiSession.currentJsonResponse().getEventList().size()); + assertEquals(event, m_uiSession.currentJsonResponse().getEventList().get(0)); + jsonPopups = assertInstance(event.getProperties().get(PopupManager.PROP_POPUPS), JSONArray.class); + assertEquals(1, jsonPopups.length()); + assertEquals(JsonAdapterUtility.findChildAdapter(jsonPopupManager, popup).getId(), jsonPopups.getString(0)); + + // popup closed -> popup is not part of property change event, no matter what its anchor looks like + + getPopupManager().close(popup); + + assertEquals(1, m_uiSession.currentJsonResponse().getEventList().size()); + assertEquals(event, m_uiSession.currentJsonResponse().getEventList().get(0)); + jsonPopups = assertInstance(event.getProperties().get(PopupManager.PROP_POPUPS), JSONArray.class); + assertEquals(0, jsonPopups.length()); + + popup.setAnchor(new MyStringField()); + + assertEquals(1, m_uiSession.currentJsonResponse().getEventList().size()); + assertEquals(event, m_uiSession.currentJsonResponse().getEventList().get(0)); + jsonPopups = assertInstance(event.getProperties().get(PopupManager.PROP_POPUPS), JSONArray.class); + assertEquals(0, jsonPopups.length()); + + popup.setAnchor(null); + + assertEquals(1, m_uiSession.currentJsonResponse().getEventList().size()); + assertEquals(event, m_uiSession.currentJsonResponse().getEventList().get(0)); + jsonPopups = assertInstance(event.getProperties().get(PopupManager.PROP_POPUPS), JSONArray.class); + assertEquals(0, jsonPopups.length()); + } + + @Test + public void testAnchorJsonAdapterChanges() { + JsonPopupManager jsonPopupManager = m_uiSession.createJsonAdapter(getPopupManager(), m_uiSession.getRootJsonAdapter()); + + // anchor without json adapter -> popup is not part of property change event + + MyPopup popup = new MyPopup(); + MyStringField anchor = new MyStringField(); + popup.setAnchor(anchor); + + getPopupManager().open(popup); + + assertEquals(1, m_uiSession.currentJsonResponse().getEventList().size()); + JsonPropertyChangeEvent event = assertInstance(m_uiSession.currentJsonResponse().getEventList().get(0), JsonPropertyChangeEvent.class); + assertEquals("property", event.getType()); + assertEquals(jsonPopupManager.getId(), event.getTarget()); + assertEquals(1, event.getProperties().size()); + assertTrue(event.getProperties().containsKey(PopupManager.PROP_POPUPS)); + JSONArray jsonPopups = assertInstance(event.getProperties().get(PopupManager.PROP_POPUPS), JSONArray.class); + assertEquals(0, jsonPopups.length()); + + // anchor with json adapter -> popup is part of property change event + + IJsonAdapter jsonAnchor = m_uiSession.createJsonAdapter(anchor, m_uiSession.getRootJsonAdapter()); + + assertEquals(1, m_uiSession.currentJsonResponse().getEventList().size()); + assertEquals(event, m_uiSession.currentJsonResponse().getEventList().get(0)); + jsonPopups = assertInstance(event.getProperties().get(PopupManager.PROP_POPUPS), JSONArray.class); + assertEquals(1, jsonPopups.length()); + assertEquals(JsonAdapterUtility.findChildAdapter(jsonPopupManager, popup).getId(), jsonPopups.getString(0)); + + // anchor without json adapter -> popup is not part of property change event + + jsonAnchor.dispose(); + + assertEquals(1, m_uiSession.currentJsonResponse().getEventList().size()); + assertEquals(event, m_uiSession.currentJsonResponse().getEventList().get(0)); + jsonPopups = assertInstance(event.getProperties().get(PopupManager.PROP_POPUPS), JSONArray.class); + assertEquals(0, jsonPopups.length()); + + // popup closed -> popup is not part of property change event, no matter what its anchor looks like + + getPopupManager().close(popup); + + jsonAnchor = m_uiSession.createJsonAdapter(anchor, m_uiSession.getRootJsonAdapter()); + + assertEquals(1, m_uiSession.currentJsonResponse().getEventList().size()); + assertEquals(event, m_uiSession.currentJsonResponse().getEventList().get(0)); + jsonPopups = assertInstance(event.getProperties().get(PopupManager.PROP_POPUPS), JSONArray.class); + assertEquals(0, jsonPopups.length()); + + jsonAnchor.dispose(); + + assertEquals(1, m_uiSession.currentJsonResponse().getEventList().size()); + assertEquals(event, m_uiSession.currentJsonResponse().getEventList().get(0)); + jsonPopups = assertInstance(event.getProperties().get(PopupManager.PROP_POPUPS), JSONArray.class); + assertEquals(0, jsonPopups.length()); + } + + protected static class MyStringField extends AbstractStringField { + } + + protected static class MyPopup extends AbstractPopup { + } +} diff --git a/org.eclipse.scout.rt.ui.html/src/main/java/org/eclipse/scout/rt/ui/html/json/popup/JsonPopupManager.java b/org.eclipse.scout.rt.ui.html/src/main/java/org/eclipse/scout/rt/ui/html/json/popup/JsonPopupManager.java index 479bdee643a..edd41bbc03d 100644 --- a/org.eclipse.scout.rt.ui.html/src/main/java/org/eclipse/scout/rt/ui/html/json/popup/JsonPopupManager.java +++ b/org.eclipse.scout.rt.ui.html/src/main/java/org/eclipse/scout/rt/ui/html/json/popup/JsonPopupManager.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2010, 2023 BSI Business Systems Integration AG + * Copyright (c) 2010, 2026 BSI Business Systems Integration AG * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -9,13 +9,24 @@ */ package org.eclipse.scout.rt.ui.html.json.popup; +import java.beans.PropertyChangeEvent; +import java.beans.PropertyChangeListener; +import java.util.HashSet; +import java.util.Objects; import java.util.Set; +import java.util.stream.Collectors; +import org.eclipse.scout.rt.client.job.ModelJobs; +import org.eclipse.scout.rt.client.ui.IWidget; import org.eclipse.scout.rt.client.ui.popup.IPopup; import org.eclipse.scout.rt.client.ui.popup.PopupManager; +import org.eclipse.scout.rt.platform.util.CollectionUtility; import org.eclipse.scout.rt.ui.html.IUiSession; +import org.eclipse.scout.rt.ui.html.UiSessionEvent; +import org.eclipse.scout.rt.ui.html.UiSessionListener; import org.eclipse.scout.rt.ui.html.json.AbstractJsonPropertyObserver; import org.eclipse.scout.rt.ui.html.json.IJsonAdapter; +import org.eclipse.scout.rt.ui.html.json.JsonAdapterUtility; import org.eclipse.scout.rt.ui.html.json.form.fields.JsonAdapterProperty; /** @@ -23,6 +34,13 @@ */ public class JsonPopupManager extends AbstractJsonPropertyObserver { + private P_PopupAnchorChangeListener m_popupAnchorChangeListener; + private P_AnchorAdapterChangeListener m_anchorAdapterChangeListener; + /** + * All currently observed anchors, used by the {@link P_AnchorAdapterChangeListener} to decide whether the json property {@link PopupManager#PROP_POPUPS} needs to be updated. + */ + private final Set m_observedAnchors = new HashSet<>(); + public JsonPopupManager(T model, IUiSession uiSession, String id, IJsonAdapter parent) { super(model, uiSession, id, parent); } @@ -35,11 +53,189 @@ public String getObjectType() { @Override protected void initJsonProperties(T model) { super.initJsonProperties(model); - putJsonProperty(new JsonAdapterProperty(PopupManager.PROP_POPUPS, model, getUiSession()) { + putJsonProperty(new JsonAdapterProperty<>(PopupManager.PROP_POPUPS, model, getUiSession()) { @Override protected Set modelValue() { - return getModel().getPopups(); + // The json adapter of a popup uses a JsonAdapterRefProperty for its anchor property. + // When the value of this property is resolved and an anchor is present, an exception is thrown if there is no json adapter for the anchor. + // Therefore, remove all popups that have anchors without a json adapter. + return getModel().getPopups().stream() + .filter(Objects::nonNull) + .filter(popup -> popup.getAnchor() == null || JsonAdapterUtility.findChildAdapter(getUiSession().getRootJsonAdapter(), popup.getAnchor()) != null) + .collect(Collectors.toSet()); } }); } + + @Override + protected void attachModel() { + super.attachModel(); + + // create listener initially + if (m_popupAnchorChangeListener != null) { + throw new IllegalStateException(); + } + m_popupAnchorChangeListener = new P_PopupAnchorChangeListener(); + + // install listener on all current popups + Set popups = getModel().getPopups(); + if (!CollectionUtility.isEmpty(popups)) { + popups.forEach(this::installPopupListeners); + } + } + + @Override + protected void detachModel() { + super.detachModel(); + + // uninstall listener from all current popups + Set popups = getModel().getPopups(); + if (!CollectionUtility.isEmpty(popups)) { + popups.forEach(this::uninstallPopupListeners); + } + } + + @Override + protected void handleModelPropertyChange(PropertyChangeEvent event) { + // update listeners for all added/removed popups + if (event.getPropertyName().equals(PopupManager.PROP_POPUPS)) { + //noinspection unchecked + handleModelPopupsChange((Set) event.getOldValue(), (Set) event.getNewValue()); + } + super.handleModelPropertyChange(event); + } + + /** + * Updates the listeners on the given {@link IPopup}s. + * Calls {@link #installPopupListeners(IPopup)} for all added {@link IPopup}s and {@link #uninstallPopupListeners(IPopup)} for all removed ones. + */ + protected void handleModelPopupsChange(Set oldPopups, Set newPopups) { + Set removedPopups = CollectionUtility.hashSetWithoutNullElements(oldPopups); + if (newPopups != null) { + removedPopups.removeAll(newPopups); + } + + Set addedPopups = CollectionUtility.hashSetWithoutNullElements(newPopups); + if (oldPopups != null) { + addedPopups.removeAll(oldPopups); + } + + removedPopups.forEach(this::uninstallPopupListeners); + addedPopups.forEach(this::installPopupListeners); + } + + /** + * Installs the {@link #m_popupAnchorChangeListener} on the given {@link IPopup}. + * Additionally, the anchor of the {@link IPopup} is added to the {@link #m_observedAnchors} (see {@link #addObservedAnchor(IWidget)}). + */ + protected void installPopupListeners(IPopup popup) { + if (popup == null) { + return; + } + + popup.addPropertyChangeListener(IPopup.PROP_ANCHOR, m_popupAnchorChangeListener); + addObservedAnchor(popup.getAnchor()); + } + + /** + * Uninstalls the {@link #m_popupAnchorChangeListener} from the given {@link IPopup}. + * Additionally, the anchor of the {@link IPopup} is removed from the {@link #m_observedAnchors} (see {@link #removeObservedAnchor(IWidget)}). + */ + protected void uninstallPopupListeners(IPopup popup) { + if (popup == null) { + return; + } + + popup.removePropertyChangeListener(IPopup.PROP_ANCHOR, m_popupAnchorChangeListener); + removeObservedAnchor(popup.getAnchor()); + } + + /** + * Adds the given anchor to the {@link #m_observedAnchors}. + * If this changes the {@link #m_observedAnchors} the json property {@link PopupManager#PROP_POPUPS} is updated (see {@link #updatePopupsJsonProperty()}) + * and the {@link #m_anchorAdapterChangeListener} is updated if necessary. + */ + protected void addObservedAnchor(IWidget anchor) { + if (anchor == null) { + return; + } + + if (!m_observedAnchors.add(anchor)) { + return; + } + updatePopupsJsonProperty(); + + // nothing to observe or listener is created already + if (m_observedAnchors.isEmpty() || m_anchorAdapterChangeListener != null) { + return; + } + + m_anchorAdapterChangeListener = new P_AnchorAdapterChangeListener(); + getUiSession().addListener(m_anchorAdapterChangeListener, UiSessionEvent.TYPE_ADAPTER_CREATED, UiSessionEvent.TYPE_ADAPTER_DISPOSED); + } + + /** + * Removes the given anchor from the {@link #m_observedAnchors}. + * If this changes the {@link #m_observedAnchors} the json property {@link PopupManager#PROP_POPUPS} is updated (see {@link #updatePopupsJsonProperty()}) + * and the {@link #m_anchorAdapterChangeListener} is updated if necessary. + */ + protected void removeObservedAnchor(IWidget anchor) { + if (anchor == null) { + return; + } + + if (!m_observedAnchors.remove(anchor)) { + return; + } + updatePopupsJsonProperty(); + + // still something to observe or listener is destroyed already + if (!m_observedAnchors.isEmpty() || m_anchorAdapterChangeListener == null) { + return; + } + + getUiSession().removeListener(m_anchorAdapterChangeListener, UiSessionEvent.TYPE_ADAPTER_CREATED, UiSessionEvent.TYPE_ADAPTER_DISPOSED); + m_anchorAdapterChangeListener = null; + } + + /** + * Updates the json property {@link PopupManager#PROP_POPUPS} of this json adapter. + */ + protected void updatePopupsJsonProperty() { + handleModelPropertyChange(new PropertyChangeEvent(getModel(), PopupManager.PROP_POPUPS, getModel().getPopups(), getModel().getPopups())); + } + + /** + * Updates the {@link #m_observedAnchors} when the anchor of a popup changes. + */ + protected class P_PopupAnchorChangeListener implements PropertyChangeListener { + + @Override + public void propertyChange(PropertyChangeEvent evt) { + ModelJobs.assertModelThread(); + removeObservedAnchor((IWidget) evt.getOldValue()); + addObservedAnchor((IWidget) evt.getNewValue()); + } + } + + /** + * Updates the json property {@link PopupManager#PROP_POPUPS} (see {@link #updatePopupsJsonProperty()}) when a json adapter is created or disposed for one of the {@link #m_observedAnchors}. + */ + protected class P_AnchorAdapterChangeListener implements UiSessionListener { + + @Override + public void sessionChanged(UiSessionEvent e) { + Object model = e.getJsonAdapter().getModel(); + if (!(model instanceof IWidget)) { + return; + } + + IWidget widget = (IWidget) model; + if (!m_observedAnchors.contains(widget)) { + return; + } + + updatePopupsJsonProperty(); + } + } }