Skip to content

Commit 36c92f3

Browse files
committed
Add pulse listener manager
1 parent eb41986 commit 36c92f3

File tree

4 files changed

+325
-0
lines changed

4 files changed

+325
-0
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*
2+
* Copyright 2016-2025 Pavel Castornii.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.techsenger.toolkit.fx.pulse;
18+
19+
/**
20+
*
21+
* @author Pavel Castornii
22+
*/
23+
public enum LayoutPhase {
24+
25+
PRE,
26+
27+
POST
28+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright 2016-2025 Pavel Castornii.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.techsenger.toolkit.fx.pulse;
18+
19+
/**
20+
*
21+
* @author Pavel Castornii
22+
*/
23+
@FunctionalInterface
24+
public interface LayoutPulseListener {
25+
26+
/**
27+
* Executes an action before/after rendering.
28+
*
29+
* @return true if the listener should remain in the collection and be called on next pulse,
30+
* false if it should be removed after execution and not be called on next pulse.
31+
*/
32+
boolean onLayoutPulse();
33+
}
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
/*
2+
* Copyright 2016-2025 Pavel Castornii.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.techsenger.toolkit.fx.pulse;
18+
19+
import java.util.List;
20+
import java.util.concurrent.CopyOnWriteArrayList;
21+
import java.util.function.Supplier;
22+
import javafx.beans.property.ReadOnlyObjectProperty;
23+
import javafx.beans.value.ChangeListener;
24+
import javafx.scene.Scene;
25+
import org.slf4j.Logger;
26+
import org.slf4j.LoggerFactory;
27+
28+
/**
29+
*
30+
* @author Pavel Castornii
31+
*/
32+
public class PulseListenerManager {
33+
34+
private static class ListenerWrapper {
35+
36+
private final LayoutPulseListener listener;
37+
38+
private boolean executed;
39+
40+
ListenerWrapper(LayoutPulseListener listener) {
41+
this.listener = listener;
42+
}
43+
44+
public LayoutPulseListener getListener() {
45+
return listener;
46+
}
47+
48+
public boolean isExecuted() {
49+
return executed;
50+
}
51+
52+
public void setExecuted(boolean executed) {
53+
this.executed = executed;
54+
}
55+
}
56+
57+
private static final Logger logger = LoggerFactory.getLogger(PulseListenerManager.class);
58+
59+
private final String parentInfo;
60+
61+
private final Supplier<ReadOnlyObjectProperty<Scene>> sceneSupplier;
62+
63+
private ChangeListener<? super Scene> sceneListener;
64+
65+
private Runnable preLayoutPulseListener;
66+
67+
private Runnable postLayoutPulseListener;
68+
69+
private final List<ListenerWrapper> preLayoutWrappers = new CopyOnWriteArrayList<>();
70+
71+
private final List<ListenerWrapper> postLayoutWrappers = new CopyOnWriteArrayList<>();
72+
73+
public PulseListenerManager(String parentInfo, Supplier<ReadOnlyObjectProperty<Scene>> sceneSupplier) {
74+
this.parentInfo = parentInfo;
75+
this.sceneSupplier = sceneSupplier;
76+
}
77+
78+
/**
79+
* Adds a layout pulse listener for the specified phase.
80+
* <p>
81+
* If a listener is added or removed during the pulse execution (inside another listener's {@code onLayoutPulse}
82+
* method), the changes will take effect only on the next pulse event. The current iteration will continue
83+
* with the snapshot of listeners that existed at the start of the pulse.
84+
*
85+
* @param phase the layout phase (PRE or POST)
86+
* @param listener the listener to add
87+
*/
88+
public void addListener(LayoutPhase phase, LayoutPulseListener listener) {
89+
//if we have scene, we add pulse listener, otherwise we add scene listener
90+
var scene = sceneSupplier.get().get();
91+
switch (phase) {
92+
case PRE:
93+
if (this.preLayoutWrappers.isEmpty()) {
94+
if (this.sceneListener == null) {
95+
if (scene == null) {
96+
addSceneListener();
97+
} else {
98+
addPreLayoutPulseListener(scene);
99+
}
100+
}
101+
}
102+
this.preLayoutWrappers.add(new ListenerWrapper(listener));
103+
break;
104+
case POST:
105+
if (this.postLayoutWrappers.isEmpty()) {
106+
if (this.sceneListener == null) {
107+
if (scene == null) {
108+
addSceneListener();
109+
} else {
110+
addPostLayoutPulseListener(scene);
111+
}
112+
}
113+
}
114+
this.postLayoutWrappers.add(new ListenerWrapper(listener));
115+
break;
116+
default:
117+
throw new AssertionError();
118+
}
119+
}
120+
121+
/**
122+
* Removes a layout pulse listener for the specified phase.
123+
* <p>
124+
* If a listener is removed during the pulse execution (inside another listener's {@code onLayoutPulse} method),
125+
* the removal will take effect only on the next pulse event. The current iteration will continue with the
126+
* snapshot of listeners that existed at the start of the pulse.
127+
*
128+
* @param phase the layout phase (PRE or POST)
129+
* @param listener the listener to remove
130+
*/
131+
public void removeListener(LayoutPhase phase, LayoutPulseListener listener) {
132+
var scene = sceneSupplier.get().get();
133+
int index;
134+
switch (phase) {
135+
case PRE:
136+
index = findListener(preLayoutWrappers, listener);
137+
if (index == -1) {
138+
return;
139+
}
140+
this.preLayoutWrappers.remove(index);
141+
if (this.sceneListener == null) {
142+
checkPreLayoutPulseListener(scene);
143+
} else {
144+
checkSceneListener();
145+
}
146+
break;
147+
case POST:
148+
index = findListener(postLayoutWrappers, listener);
149+
if (index == -1) {
150+
return;
151+
}
152+
this.postLayoutWrappers.remove(index);
153+
if (this.sceneListener == null) {
154+
checkPostLayoutPulseListener(scene);
155+
} else {
156+
checkSceneListener();
157+
}
158+
break;
159+
default:
160+
throw new AssertionError();
161+
}
162+
}
163+
164+
private int findListener(List<ListenerWrapper> wrappers, LayoutPulseListener listener) {
165+
for (var i = 0; i < wrappers.size(); i++) {
166+
var wrapper = wrappers.get(i);
167+
if (wrapper.getListener() == listener) {
168+
return i;
169+
}
170+
}
171+
return -1;
172+
}
173+
174+
private void addSceneListener() {
175+
this.sceneListener = (ov, oldV, newV) -> {
176+
if (newV != null) {
177+
if (!this.preLayoutWrappers.isEmpty()) {
178+
addPreLayoutPulseListener(newV);
179+
}
180+
if (!this.postLayoutWrappers.isEmpty()) {
181+
addPostLayoutPulseListener(newV);
182+
}
183+
removeSceneListener();
184+
}
185+
};
186+
sceneSupplier.get().addListener(this.sceneListener);
187+
logger.debug("Added scene listener for {}", this.parentInfo);
188+
}
189+
190+
private void checkSceneListener() {
191+
if (this.preLayoutWrappers.isEmpty() && this.postLayoutWrappers.isEmpty()) {
192+
removeSceneListener();
193+
}
194+
}
195+
196+
private void removeSceneListener() {
197+
if (this.sceneListener != null) {
198+
sceneSupplier.get().removeListener(this.sceneListener);
199+
this.sceneListener = null;
200+
logger.debug("Removed scene listener for {}", this.parentInfo);
201+
}
202+
}
203+
204+
private void addPreLayoutPulseListener(Scene scene) {
205+
this.preLayoutPulseListener = () -> {
206+
callListeners(this.preLayoutWrappers);
207+
checkPreLayoutPulseListener(scene);
208+
};
209+
scene.addPreLayoutPulseListener(this.preLayoutPulseListener);
210+
logger.debug("Added pre layout pulse listener for {}", this.parentInfo);
211+
}
212+
213+
private void checkPreLayoutPulseListener(Scene scene) {
214+
if (this.preLayoutWrappers.isEmpty()) {
215+
removePreLayoutPulseListener(scene);
216+
}
217+
}
218+
219+
private void removePreLayoutPulseListener(Scene scene) {
220+
if (this.preLayoutPulseListener != null) {
221+
scene.removePreLayoutPulseListener(this.preLayoutPulseListener);
222+
this.preLayoutPulseListener = null;
223+
logger.debug("Removed pre layout pulse listener for {}", this.parentInfo);
224+
}
225+
}
226+
227+
private void addPostLayoutPulseListener(Scene scene) {
228+
this.postLayoutPulseListener = () -> {
229+
callListeners(this.postLayoutWrappers);
230+
checkPostLayoutPulseListener(scene);
231+
};
232+
scene.addPostLayoutPulseListener(this.postLayoutPulseListener);
233+
logger.debug("Added post layout pulse listener for {}", this.parentInfo);
234+
}
235+
236+
private void checkPostLayoutPulseListener(Scene scene) {
237+
if (this.postLayoutWrappers.isEmpty()) {
238+
removePostLayoutPulseListener(scene);
239+
}
240+
}
241+
242+
private void removePostLayoutPulseListener(Scene scene) {
243+
if (this.postLayoutPulseListener != null) {
244+
scene.removePostLayoutPulseListener(this.postLayoutPulseListener);
245+
this.postLayoutPulseListener = null;
246+
logger.debug("Removed post layout pulse listener for {}", this.parentInfo);
247+
}
248+
}
249+
250+
private void callListeners(List<ListenerWrapper> wrappers) {
251+
boolean executedPresent = false;
252+
for (var wrapper : wrappers) {
253+
var listener = wrapper.getListener();
254+
if (!listener.onLayoutPulse()) {
255+
wrapper.setExecuted(true);
256+
executedPresent = true;
257+
}
258+
}
259+
if (executedPresent) {
260+
wrappers.removeIf(ListenerWrapper::isExecuted);
261+
}
262+
}
263+
}

toolkit-fx/src/main/java/module-info.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
exports com.techsenger.toolkit.fx.collections;
2323
exports com.techsenger.toolkit.fx.color;
2424
exports com.techsenger.toolkit.fx.input;
25+
exports com.techsenger.toolkit.fx.pulse;
2526
exports com.techsenger.toolkit.fx.table;
2627
exports com.techsenger.toolkit.fx.utils;
2728
exports com.techsenger.toolkit.fx.value;

0 commit comments

Comments
 (0)