Skip to content

Commit ef77f14

Browse files
committed
Have chats respond to PgUp and PgDown
Resolves #2087
1 parent c55c316 commit ef77f14

File tree

5 files changed

+193
-0
lines changed

5 files changed

+193
-0
lines changed
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/*
2+
* This file is part of Bisq.
3+
*
4+
* Bisq is free software: you can redistribute it and/or modify it
5+
* under the terms of the GNU Affero General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or (at
7+
* your option) any later version.
8+
*
9+
* Bisq is distributed in the hope that it will be useful, but WITHOUT
10+
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
11+
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
12+
* License for more details.
13+
*
14+
* You should have received a copy of the GNU Affero General Public License
15+
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
16+
*/
17+
18+
package bisq.desktop.common.utils;
19+
20+
import bisq.desktop.components.list_view.ListViewUtil;
21+
import javafx.event.EventHandler;
22+
import javafx.geometry.Orientation;
23+
import javafx.scene.Node;
24+
import javafx.scene.Scene;
25+
import javafx.scene.control.ListView;
26+
import javafx.scene.control.ScrollBar;
27+
import javafx.scene.control.TextInputControl;
28+
import javafx.scene.input.KeyCode;
29+
import javafx.scene.input.KeyEvent;
30+
import lombok.extern.slf4j.Slf4j;
31+
import org.fxmisc.easybind.Subscription;
32+
33+
import java.util.Objects;
34+
35+
/**
36+
* Handler to enable page-wise keyboard scrolling for a JavaFX ListView Control.
37+
* <p>
38+
* Utility that installs a key event handler on a JavaFX ListView to perform page-wise
39+
* scrolling when the PAGE_UP and PAGE_DOWN keys are pressed. The handler locates the
40+
* control's vertical ScrollBar (if present) and adjusts its value by the ScrollBar's
41+
* block increment, mimicking a page click on the scrollbar trough. The key event is
42+
* consumed only when a scroll action is performed.
43+
* </p>
44+
* <p>
45+
* Note: the handler is registered using {@code addEventFilter}, i.e. it runs in the
46+
* capturing phase (filter phase) while events travel from the root toward the target node.
47+
* This allows centralized preprocessing of PageUp/PageDown keys for the ListView before
48+
* the event reaches the target node.
49+
* </p>
50+
* <p>
51+
* To ensure that input fields keep their normal keyboard behavior, the handler
52+
* checks with {@link #isInlineEditorActive()} whether a descendant editable
53+
* {@link TextInputControl} is currently focused. If an editor is active, the filter does
54+
* not consume the event — the event will continue to the normal target/bubbling phase and
55+
* can be handled by the editor. If no editor is focused, the filter consumes the event
56+
* (via {@code event.consume()}) after a scroll occurred.
57+
* </p>
58+
* <p>
59+
* Typical usage:
60+
* <ul>
61+
* <li>Instantiate with a JavaFX ListView Control.</li>
62+
* <li>Call {@link #subscribe()} to enable the handler.</li>
63+
* <li>Call {@link #unsubscribe()} to remove the handler.</li>
64+
* </ul>
65+
* </p>
66+
* <p>
67+
* Note: methods that inspect or modify the scene graph (e.g. locating ScrollBars)
68+
* must be called on the JavaFX Application Thread.
69+
* </p>
70+
*/
71+
@Slf4j
72+
public class PageScrollHandler implements Subscription {
73+
74+
private final ListView<?> listView;
75+
private final EventHandler<KeyEvent> keyEventFilter;
76+
77+
public PageScrollHandler(ListView<?> listView) {
78+
this.listView = Objects.requireNonNull(listView);
79+
this.keyEventFilter = createKeyEventFilter();
80+
}
81+
82+
private EventHandler<KeyEvent> createKeyEventFilter() {
83+
return event -> {
84+
if (event.getCode() == KeyCode.PAGE_UP) {
85+
if (!isInlineEditorActive()) {
86+
if (scrollPageUp()) {
87+
event.consume();
88+
}
89+
}
90+
} else if (event.getCode() == KeyCode.PAGE_DOWN) {
91+
if (!isInlineEditorActive()) {
92+
if (scrollPageDown()) {
93+
event.consume();
94+
}
95+
}
96+
}
97+
};
98+
}
99+
100+
public void subscribe() {
101+
unsubscribe();
102+
listView.addEventFilter(KeyEvent.KEY_PRESSED, keyEventFilter);
103+
}
104+
105+
public void unsubscribe() {
106+
listView.removeEventFilter(KeyEvent.KEY_PRESSED, keyEventFilter);
107+
}
108+
109+
public boolean scrollPageUp() {
110+
return ListViewUtil.findScrollbar(listView, Orientation.VERTICAL)
111+
.map(this::adjustScrollBarBlockDecrement)
112+
.orElse(false);
113+
}
114+
115+
public boolean scrollPageDown() {
116+
return ListViewUtil.findScrollbar(listView, Orientation.VERTICAL)
117+
.map(this::adjustScrollBarBlockIncrement)
118+
.orElse(false);
119+
}
120+
121+
private boolean adjustScrollBarBlockDecrement(ScrollBar vbar) {
122+
return adjustScrollBar(vbar, -vbar.getBlockIncrement());
123+
}
124+
125+
private boolean adjustScrollBarBlockIncrement(ScrollBar vbar) {
126+
return adjustScrollBar(vbar, vbar.getBlockIncrement());
127+
}
128+
129+
private boolean adjustScrollBar(ScrollBar vbar, double increment) {
130+
double oldValue = vbar.getValue();
131+
vbar.adjustValue(oldValue + increment);
132+
return oldValue != vbar.getValue();
133+
}
134+
135+
private boolean isInlineEditorActive() {
136+
Scene scene = listView.getScene();
137+
if (scene == null) return false;
138+
Node focusOwner = scene.getFocusOwner();
139+
return isEditable(focusOwner) && isChildOfList(focusOwner);
140+
}
141+
142+
private boolean isEditable(Node focusOwner) {
143+
return focusOwner instanceof TextInputControl && ((TextInputControl) focusOwner).isEditable();
144+
}
145+
146+
private boolean isChildOfList(Node node) {
147+
while (node != null) {
148+
if (node == listView) return true;
149+
node = node.getParent();
150+
}
151+
return false;
152+
}
153+
}

apps/desktop/desktop/src/main/java/bisq/desktop/main/content/chat/message_container/ChatMessageContainerController.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,14 @@ void onArrowUpKeyPressed() {
217217
chatMessagesListController.editMyLastMessage();
218218
}
219219

220+
boolean onPageUpKeyPressed() {
221+
return chatMessagesListController.scrollPageUp();
222+
}
223+
224+
boolean onPageDownKeyPressed() {
225+
return chatMessagesListController.scrollPageDown();
226+
}
227+
220228
void onUserProfileSelected(UserProfile user) {
221229
String content = model.getTextInput().get().replaceAll("@[a-zA-Z\\d]*$", "@" + user.getUserName() + " ");
222230
model.getTextInput().set(content);

apps/desktop/desktop/src/main/java/bisq/desktop/main/content/chat/message_container/ChatMessageContainerView.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import javafx.scene.image.ImageView;
3737
import javafx.scene.input.KeyCode;
3838
import javafx.scene.input.KeyEvent;
39+
import javafx.scene.input.MouseEvent;
3940
import javafx.scene.layout.HBox;
4041
import javafx.scene.layout.Pane;
4142
import javafx.scene.layout.Priority;
@@ -89,6 +90,9 @@ public ChatMessageContainerView(ChatMessageContainerModel model,
8990

9091
@Override
9192
protected void onViewAttached() {
93+
// Consume mouse pressed events on the root to prevent them from propagating to underlying layers
94+
root.setOnMousePressed(MouseEvent::consume);
95+
9296
userProfileSelectionRoot.visibleProperty().bind(model.getShouldShowUserProfileSelection());
9397
userProfileSelectionRoot.managedProperty().bind(model.getShouldShowUserProfileSelection());
9498
myProfileCatHashImageView.visibleProperty().bind(model.getShouldShowUserProfile());
@@ -152,6 +156,7 @@ protected void onViewDetached() {
152156
inputField.removeEventFilter(KEY_PRESSED, keyPressedHandler);
153157
sendButton.setOnAction(null);
154158
userMentionPopup.cleanup();
159+
root.setOnMousePressed(null);
155160
}
156161

157162
private VBox createAndGetBottomBar(UserProfileSelection userProfileSelection) {
@@ -256,6 +261,18 @@ private void processKeyPressed(KeyEvent keyEvent) {
256261
inputField.positionCaret(0);
257262
}
258263
}
264+
} else if (keyEvent.getCode() == KeyCode.PAGE_UP) {
265+
if (inputField.getText().isEmpty()) {
266+
if (controller.onPageUpKeyPressed()) {
267+
keyEvent.consume();
268+
}
269+
}
270+
} else if (keyEvent.getCode() == KeyCode.PAGE_DOWN) {
271+
if (inputField.getText().isEmpty()) {
272+
if (controller.onPageDownKeyPressed()) {
273+
keyEvent.consume();
274+
}
275+
}
259276
}
260277
}
261278
}

apps/desktop/desktop/src/main/java/bisq/desktop/main/content/chat/message_container/list/ChatMessagesListController.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,13 @@ void onScrollToBottom() {
669669
applyScrollValue(1);
670670
}
671671

672+
public boolean scrollPageUp() {
673+
return view.getPageScrollHandler().scrollPageUp();
674+
}
675+
676+
public boolean scrollPageDown() {
677+
return view.getPageScrollHandler().scrollPageDown();
678+
}
672679

673680
/* --------------------------------------------------------------------- */
674681
// Private

apps/desktop/desktop/src/main/java/bisq/desktop/main/content/chat/message_container/list/ChatMessagesListView.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import bisq.chat.ChatChannel;
2121
import bisq.chat.ChatMessage;
2222
import bisq.desktop.common.ManagedDuration;
23+
import bisq.desktop.common.utils.PageScrollHandler;
2324
import bisq.desktop.components.controls.Badge;
2425
import bisq.desktop.components.controls.BisqTooltip;
2526
import bisq.desktop.components.list_view.ListViewUtil;
@@ -46,6 +47,7 @@
4647
import javafx.scene.layout.Priority;
4748
import javafx.scene.layout.StackPane;
4849
import javafx.scene.layout.VBox;
50+
import lombok.AccessLevel;
4951
import lombok.Getter;
5052
import lombok.extern.slf4j.Slf4j;
5153
import org.fxmisc.easybind.EasyBind;
@@ -74,6 +76,8 @@ protected void layoutChildren() {
7476
private final Label placeholderTitle = new Label("");
7577
private final Label placeholderDescription = new Label("");
7678
private final Pane scrollDownBackground;
79+
@Getter(AccessLevel.PACKAGE)
80+
private final PageScrollHandler pageScrollHandler;
7781
private Optional<ScrollBar> scrollBar = Optional.empty();
7882
private Subscription hasUnreadMessagesPin, showScrolledDownButtonPin;
7983
private Timeline fadeInScrollDownBadgeTimeline;
@@ -93,6 +97,8 @@ public ChatMessagesListView(ChatMessagesListModel model, ChatMessagesListControl
9397
listView.setSelectionModel(new NoSelectionModel<>());
9498
VBox.setVgrow(listView, Priority.ALWAYS);
9599

100+
pageScrollHandler = new PageScrollHandler(listView);
101+
96102
scrollDownBackground = new Pane();
97103
scrollDownBackground.getStyleClass().add("scroll-down-bg");
98104

@@ -171,6 +177,7 @@ protected void onViewAttached() {
171177
model.getLayoutChildrenDone().bind(root.getLayoutChildrenDone());
172178

173179
scrollDownBadge.setOnMouseClicked(e -> controller.onScrollToBottom());
180+
pageScrollHandler.subscribe();
174181
}
175182

176183
@Override
@@ -185,6 +192,7 @@ protected void onViewDetached() {
185192
model.getLayoutChildrenDone().unbind();
186193
hasUnreadMessagesPin.unsubscribe();
187194
showScrolledDownButtonPin.unsubscribe();
195+
pageScrollHandler.unsubscribe();
188196

189197
Tooltip.uninstall(scrollDownBadge, scrollDownTooltip);
190198

0 commit comments

Comments
 (0)