Skip to content

Commit 1955ee0

Browse files
authored
Merge pull request #56 from mcgivrer/copilot/fix-wheelmouse-zoom-linux
Fix scroll-wheel zoom-in blocked on Linux in Network Diagram
2 parents b1399a3 + db6a8d0 commit 1955ee0

4 files changed

Lines changed: 234 additions & 6 deletions

File tree

‎src/docs/user-guide-en.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -644,7 +644,7 @@ This prevents visual clutter and improves performance with large projects.
644644
| **Click an edge** | Open the nearest connected document node |
645645
| **Drag a node** | Move a node to rearrange the layout (pins during drag, unpins on release) |
646646
| **Middle-click + drag** | Pan the view |
647-
| **Scroll wheel** | Zoom in/out (range: 0.1× – 8.0×, centered on cursor) |
647+
| **Scroll wheel** | Zoom in/out (range: 0.1× – 8.0×, factor 1.15× per step, centered on cursor; works on all platforms including Linux) |
648648
| **Ctrl + Click** | Reset zoom and recenter (zoom-to-fit) |
649649

650650
### Tooltips

‎src/main/java/ui/VisualLinkPanel.java‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ public class VisualLinkPanel extends BasePanel {
9191
private int stableFrames = 0;
9292
private boolean fitDone = false;
9393

94+
// ── Zoom ────────────────────────────────────────────────────
95+
// Zoom constants are defined in ZoomCalculator (pure-Java, testable without JavaFX)
96+
9497
// ── Interaction ─────────────────────────────────────────────
9598
private static final double EDGE_HIT_TOLERANCE = 6.0;
9699
private GraphNode draggedNode = null;
@@ -322,10 +325,22 @@ private void setupInteraction() {
322325
canvas.addEventFilter(ScrollEvent.SCROLL, this::handleZoomScroll);
323326
}
324327

328+
/**
329+
* Handles a scroll-wheel event on the canvas and adjusts the zoom level.
330+
*
331+
* <p>On Linux, {@link javafx.scene.input.ScrollEvent#getDeltaY()} may return
332+
* {@code 0.0} for physical mouse-wheel rotations. In that case the method
333+
* falls back to {@link javafx.scene.input.ScrollEvent#getTextDeltaY()}.
334+
* If both deltas are zero the event is silently consumed and no zoom change
335+
* occurs, preventing the erroneous zoom-out that used to block zoom-in on
336+
* Linux.</p>
337+
*
338+
* @param e the scroll event delivered by JavaFX
339+
*/
325340
private void handleZoomScroll(ScrollEvent e) {
326-
double factor = e.getDeltaY() > 0 ? 1.15 : 1.0 / 1.15;
327-
double newZoom = zoom * factor;
328-
newZoom = Math.max(0.1, Math.min(8.0, newZoom));
341+
double newZoom = ZoomCalculator.computeNewZoom(
342+
e.getDeltaY(), e.getTextDeltaY(), zoom,
343+
ZoomCalculator.ZOOM_FACTOR, ZoomCalculator.ZOOM_MIN, ZoomCalculator.ZOOM_MAX);
329344
if (Math.abs(newZoom - zoom) < 1e-9) {
330345
e.consume();
331346
return;
@@ -1362,7 +1377,7 @@ public void zoomToFit() {
13621377
} else {
13631378
zoom = Math.min(availW / graphW, availH / graphH);
13641379
}
1365-
zoom = Math.max(0.1, Math.min(8.0, zoom));
1380+
zoom = Math.max(ZoomCalculator.ZOOM_MIN, Math.min(ZoomCalculator.ZOOM_MAX, zoom));
13661381

13671382
// Centrer le graphe dans le canvas
13681383
double centerX = (minX + maxX) / 2.0;
@@ -1392,7 +1407,7 @@ private void zoomToComponent(ComponentCircle circle) {
13921407
if (availW < 1 || availH < 1) return;
13931408

13941409
zoom = Math.min(availW / diameter, availH / diameter);
1395-
zoom = Math.max(0.1, Math.min(8.0, zoom));
1410+
zoom = Math.max(ZoomCalculator.ZOOM_MIN, Math.min(ZoomCalculator.ZOOM_MAX, zoom));
13961411

13971412
// Centrer sur le cercle
13981413
panX = cw / 2.0 - circle.centerX * zoom;
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package ui;
2+
3+
/**
4+
* Stateless helper that computes scroll-wheel zoom levels for the
5+
* {@link VisualLinkPanel} canvas.
6+
*
7+
* <p>The class is intentionally decoupled from JavaFX so that the core
8+
* calculation can be unit-tested without a running JavaFX runtime (following
9+
* the same pattern as {@link TreeExpansionState}).</p>
10+
*
11+
* <h2>Linux scroll-wheel fix</h2>
12+
* <p>On Linux (GTK/X11/Wayland), {@code ScrollEvent.getDeltaY()} can return
13+
* {@code 0.0} for ordinary mouse-wheel rotations while the actual scroll
14+
* direction is carried by {@code ScrollEvent.getTextDeltaY()}. The previous
15+
* code evaluated {@code getDeltaY() > 0 ? zoomIn : zoomOut}; when
16+
* {@code getDeltaY()} was {@code 0} the condition was always {@code false},
17+
* so <em>every</em> zero-delta event triggered a zoom-out step — effectively
18+
* blocking zoom-in on affected Linux configurations.</p>
19+
* <p>This class fixes the issue by falling back to {@code textDeltaY} when
20+
* {@code deltaY} is zero, and ignoring the event entirely when both values
21+
* are zero.</p>
22+
*/
23+
final class ZoomCalculator {
24+
25+
/** Multiplicative factor applied per scroll-wheel step (zoom in). */
26+
static final double ZOOM_FACTOR = 1.15;
27+
/** Minimum allowed zoom level. */
28+
static final double ZOOM_MIN = 0.1;
29+
/** Maximum allowed zoom level. */
30+
static final double ZOOM_MAX = 8.0;
31+
32+
private ZoomCalculator() {
33+
// utility class — do not instantiate
34+
}
35+
36+
/**
37+
* Computes the new zoom level after a scroll-wheel event.
38+
*
39+
* <p>The method uses {@code textDeltaY} only when {@code deltaY} is
40+
* exactly {@code 0.0}, providing cross-platform correctness without
41+
* altering the behaviour on Windows and macOS where {@code deltaY} is
42+
* always non-zero for mouse-wheel events.</p>
43+
*
44+
* @param deltaY vertical scroll delta from
45+
* {@code ScrollEvent.getDeltaY()}; positive means
46+
* scroll up (zoom in), negative means scroll down
47+
* (zoom out)
48+
* @param textDeltaY text-unit vertical scroll delta from
49+
* {@code ScrollEvent.getTextDeltaY()}; used as a
50+
* fallback only when {@code deltaY} is zero (Linux)
51+
* @param currentZoom the current zoom level before the event
52+
* @param zoomFactor the multiplicative factor applied per scroll step
53+
* (e.g. {@code 1.15}); must be &gt; 1
54+
* @param minZoom the minimum allowed zoom level (e.g. {@code 0.1});
55+
* must be positive
56+
* @param maxZoom the maximum allowed zoom level (e.g. {@code 8.0});
57+
* must be &gt; {@code minZoom}
58+
* @return the clamped new zoom level; equal to {@code currentZoom} when
59+
* both deltas are zero (no-op event)
60+
*/
61+
static double computeNewZoom(double deltaY, double textDeltaY,
62+
double currentZoom, double zoomFactor,
63+
double minZoom, double maxZoom) {
64+
// On Linux, getDeltaY() can be 0; fall back to getTextDeltaY()
65+
double effectiveDelta = deltaY != 0.0 ? deltaY : textDeltaY;
66+
if (effectiveDelta == 0.0) {
67+
// Both deltas are zero — nothing to do
68+
return currentZoom;
69+
}
70+
double factor = effectiveDelta > 0 ? zoomFactor : 1.0 / zoomFactor;
71+
double newZoom = currentZoom * factor;
72+
return Math.max(minZoom, Math.min(maxZoom, newZoom));
73+
}
74+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package ui;
2+
3+
import org.junit.jupiter.api.Test;
4+
import static org.junit.jupiter.api.Assertions.*;
5+
6+
/**
7+
* Unit tests for the zoom calculation logic in {@link ZoomCalculator}.
8+
*
9+
* <p>All tests exercise {@link ZoomCalculator#computeNewZoom} as a pure,
10+
* JavaFX-free function. This avoids the need for a running JavaFX runtime and
11+
* makes the suite fast and portable.</p>
12+
*
13+
* <p>The primary motivation for these tests is the Linux scroll-wheel bug:
14+
* on Linux, {@code ScrollEvent.getDeltaY()} can return {@code 0.0} for
15+
* physical mouse-wheel rotations. The previous code treated every zero-delta
16+
* event as a zoom-out step, making zoom-in impossible. The fixed
17+
* {@link ZoomCalculator#computeNewZoom} method falls back to
18+
* {@code textDeltaY} when {@code deltaY} is zero, and ignores the event
19+
* completely when both are zero.</p>
20+
*/
21+
public class VisualLinkPanelZoomTest {
22+
23+
// ── constants that mirror VisualLinkPanel's zoom configuration ─────────
24+
private static final double FACTOR = ZoomCalculator.ZOOM_FACTOR; // 1.15
25+
private static final double MIN = ZoomCalculator.ZOOM_MIN; // 0.1
26+
private static final double MAX = ZoomCalculator.ZOOM_MAX; // 8.0
27+
private static final double CURRENT = 1.0;
28+
private static final double DELTA = 1e-9;
29+
30+
/** Helper: call computeNewZoom with the standard constants. */
31+
private static double zoom(double deltaY, double textDeltaY, double current) {
32+
return ZoomCalculator.computeNewZoom(deltaY, textDeltaY, current, FACTOR, MIN, MAX);
33+
}
34+
35+
// ── normal scroll (getDeltaY() != 0) ──────────────────────────────────
36+
37+
@Test
38+
void scrollUp_positiveDelta_zoomsIn() {
39+
double result = zoom(40.0, 0.0, CURRENT);
40+
assertEquals(CURRENT * FACTOR, result, DELTA,
41+
"positive deltaY must produce a zoom-in (multiply by factor)");
42+
}
43+
44+
@Test
45+
void scrollDown_negativeDelta_zoomsOut() {
46+
double result = zoom(-40.0, 0.0, CURRENT);
47+
assertEquals(CURRENT / FACTOR, result, DELTA,
48+
"negative deltaY must produce a zoom-out (divide by factor)");
49+
}
50+
51+
// ── Linux fallback: getDeltaY() == 0, use textDeltaY ─────────────────
52+
53+
@Test
54+
void linuxScrollUp_zeroDelta_positiveTextDelta_zoomsIn() {
55+
// Simulates a Linux mouse-wheel-up event where getDeltaY() == 0
56+
double result = zoom(0.0, 1.0, CURRENT);
57+
assertEquals(CURRENT * FACTOR, result, DELTA,
58+
"when deltaY == 0, positive textDeltaY must still produce a zoom-in");
59+
}
60+
61+
@Test
62+
void linuxScrollDown_zeroDelta_negativeTextDelta_zoomsOut() {
63+
// Simulates a Linux mouse-wheel-down event where getDeltaY() == 0
64+
double result = zoom(0.0, -1.0, CURRENT);
65+
assertEquals(CURRENT / FACTOR, result, DELTA,
66+
"when deltaY == 0, negative textDeltaY must still produce a zoom-out");
67+
}
68+
69+
@Test
70+
void bothDeltasZero_returnsCurrentZoom() {
71+
// Both deltas zero → no scroll occurred; zoom must not change
72+
double result = zoom(0.0, 0.0, CURRENT);
73+
assertEquals(CURRENT, result, DELTA,
74+
"when both deltas are zero, zoom must remain unchanged");
75+
}
76+
77+
// ── clamping ──────────────────────────────────────────────────────────
78+
79+
@Test
80+
void zoomIn_atMaximum_clampedToMax() {
81+
double result = zoom(40.0, 0.0, MAX);
82+
assertEquals(MAX, result, DELTA,
83+
"zoom-in at maximum must be clamped to ZOOM_MAX");
84+
}
85+
86+
@Test
87+
void zoomOut_atMinimum_clampedToMin() {
88+
double result = zoom(-40.0, 0.0, MIN);
89+
assertEquals(MIN, result, DELTA,
90+
"zoom-out at minimum must be clamped to ZOOM_MIN");
91+
}
92+
93+
@Test
94+
void zoomIn_nearMaximum_clampedToMax() {
95+
// A zoom level just below MAX × FACTOR would exceed the limit
96+
double nearMax = MAX / FACTOR + 0.001;
97+
double result = zoom(40.0, 0.0, nearMax);
98+
assertEquals(MAX, result, DELTA,
99+
"zoom-in result exceeding ZOOM_MAX must be clamped to ZOOM_MAX");
100+
}
101+
102+
@Test
103+
void zoomOut_nearMinimum_clampedToMin() {
104+
double nearMin = MIN * FACTOR - 0.001;
105+
double result = zoom(-40.0, 0.0, nearMin);
106+
assertEquals(MIN, result, DELTA,
107+
"zoom-out result below ZOOM_MIN must be clamped to ZOOM_MIN");
108+
}
109+
110+
// ── symmetry: zoom-in then zoom-out should round-trip ─────────────────
111+
112+
@Test
113+
void zoomInThenOut_isApproximatelySymmetric() {
114+
double afterIn = zoom(40.0, 0.0, CURRENT);
115+
double afterOut = zoom(-40.0, 0.0, afterIn);
116+
assertEquals(CURRENT, afterOut, 1e-6,
117+
"one zoom-in followed by one zoom-out must return to the original level");
118+
}
119+
120+
@Test
121+
void linuxFallback_zoomInThenOut_isApproximatelySymmetric() {
122+
// Same round-trip test but using the Linux textDeltaY path
123+
double afterIn = zoom(0.0, 1.0, CURRENT);
124+
double afterOut = zoom(0.0, -1.0, afterIn);
125+
assertEquals(CURRENT, afterOut, 1e-6,
126+
"Linux textDeltaY zoom-in then zoom-out must round-trip correctly");
127+
}
128+
129+
// ── deltaY takes precedence over textDeltaY ───────────────────────────
130+
131+
@Test
132+
void nonZeroDeltaY_overridesTextDeltaY() {
133+
// deltaY positive → zoom in, even though textDeltaY is negative
134+
double result = zoom(40.0, -999.0, CURRENT);
135+
assertEquals(CURRENT * FACTOR, result, DELTA,
136+
"non-zero deltaY must take precedence over textDeltaY");
137+
}
138+
}
139+

0 commit comments

Comments
 (0)