Skip to content

Commit 08bc6b8

Browse files
committed
Implement JBR-style resize interception on Linux Tao
Move resize hit-test, cursor management, and drag initiation from Tao's GTK event handlers to the Compose scene host boundary (onPointerMove/onPointerButton). This ensures resize claims events BEFORE scene.sendPointerEvent, preventing Compose scrollbars from stealing clicks on window edges. Matches JBR's WLDecoratedPeer.postMouseEvent + FrameDecoration pattern with 5px edge band and natural corner detection via edge intersection.
1 parent 910879d commit 08bc6b8

5 files changed

Lines changed: 233 additions & 128 deletions

File tree

decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NativeTaoBridge.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,23 @@ internal object NativeTaoBridge {
256256
@JvmStatic
257257
external fun nativeDragWindow(handle: Long)
258258

259+
/**
260+
* Begin an interactive resize drag in [direction]. Linux-only meaningful
261+
* today (X11 + Wayland through Tao's `Window::drag_resize_window`). The
262+
* Compose-side `ResizeFrameDecoration` calls this from `onPointerButton`
263+
* BEFORE forwarding the press to `scene.sendPointerEvent`, so it claims
264+
* clicks even on top of a Compose scrollbar — same architectural pattern
265+
* as JBR's `WLDecoratedPeer.startResize(...)`.
266+
*
267+
* Direction is the ordinal of `ResizeDirection`: 0=N, 1=S, 2=E, 3=W,
268+
* 4=NW, 5=NE, 6=SW, 7=SE.
269+
*/
270+
@JvmStatic
271+
external fun nativeBeginResizeDrag(
272+
handle: Long,
273+
direction: Int,
274+
)
275+
259276
@JvmStatic
260277
external fun nativeIsMaximized(handle: Long): Boolean
261278

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
@file:Suppress("MagicNumber")
2+
3+
package dev.nucleusframework.window.tao.render
4+
5+
import dev.nucleusframework.window.tao.NativeTaoBridge
6+
import dev.nucleusframework.window.tao.TaoCursorIcon
7+
8+
/**
9+
* Compose-side peer-level resize hit-test, structurally identical to JBR's
10+
* `sun.awt.wl.FrameDecoration`. Plugged into [TaoComposeSceneHostLinux] at the
11+
* input-dispatch boundary (`onPointerMove` / `onPointerButton`) so that resize
12+
* events are claimed BEFORE `scene.sendPointerEvent` runs — meaning Compose
13+
* never sees a click that was meant for resize, even when the click lands on
14+
* top of a scrollbar pinned to the window edge.
15+
*
16+
* Coordinates are logical pixels in the window's local frame (origin at
17+
* top-left). Band thickness matches the JBR default
18+
* (`FrameDecoration.DEFAULT_RESIZE_EDGE_THICKNESS = 5`), with corner detection
19+
* emerging naturally from the intersection of two edges (no separate, wider
20+
* corner zone — same precedence as JBR's `getResizeEdges` bitmask).
21+
*/
22+
internal class ResizeFrameDecoration(
23+
private val windowHandle: Long,
24+
/**
25+
* Edge band thickness in logical pixels. Matches JBR's
26+
* `FrameDecoration.DEFAULT_RESIZE_EDGE_THICKNESS = 5`. There is no
27+
* separate, wider "corner" band — diagonal resize is detected as the
28+
* intersection of two edges (top-left = top edge AND left edge), which
29+
* gives a natural 5×5 corner hotspot. Widening it makes the band feel
30+
* heavy on small windows; AWT's 5 px is the empirically-tuned sweet spot.
31+
*/
32+
private val edgeThicknessLogical: Int = 5,
33+
) {
34+
/** Ordinals MUST match `NativeTaoBridge.nativeBeginResizeDrag` direction encoding. */
35+
enum class Direction(
36+
val code: Int,
37+
val cursorIcon: Int,
38+
) {
39+
North(0, TaoCursorIcon.NS_RESIZE),
40+
South(1, TaoCursorIcon.NS_RESIZE),
41+
East(2, TaoCursorIcon.EW_RESIZE),
42+
West(3, TaoCursorIcon.EW_RESIZE),
43+
NorthWest(4, TaoCursorIcon.NWSE_RESIZE),
44+
NorthEast(5, TaoCursorIcon.NESW_RESIZE),
45+
SouthWest(6, TaoCursorIcon.NESW_RESIZE),
46+
SouthEast(7, TaoCursorIcon.NWSE_RESIZE),
47+
}
48+
49+
private var inBand: Boolean = false
50+
51+
/**
52+
* Returns the resize direction if [x], [y] (logical px, window-local) sits
53+
* in the edge band of a window sized [widthLogical] × [heightLogical].
54+
* Returns `null` if outside the band — caller forwards the event normally.
55+
*
56+
* Corner zones win over edge zones (a click at (4, 4) on a 200×200 window
57+
* is `NorthWest`, not `North`). This matches the JBR + Tao precedence.
58+
*/
59+
fun hitTest(
60+
x: Float,
61+
y: Float,
62+
widthLogical: Int,
63+
heightLogical: Int,
64+
): Direction? {
65+
if (widthLogical <= 0 || heightLogical <= 0) return null
66+
val edge = edgeThicknessLogical
67+
val nearLeft = x < edge
68+
val nearRight = x >= widthLogical - edge
69+
val nearTop = y < edge
70+
val nearBottom = y >= heightLogical - edge
71+
72+
// Corner = intersection of two edges, exactly like JBR's
73+
// `getResizeEdges` bitmask precedence.
74+
return when {
75+
nearLeft && nearTop -> Direction.NorthWest
76+
nearRight && nearTop -> Direction.NorthEast
77+
nearLeft && nearBottom -> Direction.SouthWest
78+
nearRight && nearBottom -> Direction.SouthEast
79+
nearLeft -> Direction.West
80+
nearRight -> Direction.East
81+
nearTop -> Direction.North
82+
nearBottom -> Direction.South
83+
else -> null
84+
}
85+
}
86+
87+
/**
88+
* Pointer-move hook. If [direction] is non-null the cursor is updated to
89+
* the matching resize icon and the caller MUST NOT forward the move to
90+
* Compose (the band owns the pointer). When the pointer transitions out
91+
* of the band the cursor override is cleared so Compose's
92+
* `PointerIcon`-driven cursor takes over again on the next move.
93+
*
94+
* Returns `true` if the event was consumed and must NOT be forwarded.
95+
*/
96+
fun onMove(direction: Direction?): Boolean {
97+
if (direction != null) {
98+
NativeTaoBridge.nativeSetCursorIcon(windowHandle, direction.cursorIcon)
99+
inBand = true
100+
return true
101+
}
102+
if (inBand) {
103+
inBand = false
104+
// Restore the default cursor immediately; Compose will overwrite
105+
// it on the next motion if a `PointerIcon` modifier is in scope.
106+
NativeTaoBridge.nativeSetCursorIcon(windowHandle, TaoCursorIcon.DEFAULT)
107+
}
108+
return false
109+
}
110+
111+
/**
112+
* Left-mouse-button press hook. If [direction] is non-null we start the
113+
* Tao resize drag and the caller MUST NOT forward the press to Compose.
114+
*
115+
* Returns `true` if the event was consumed.
116+
*/
117+
fun onLeftPress(direction: Direction?): Boolean {
118+
if (direction == null) return false
119+
NativeTaoBridge.nativeBeginResizeDrag(windowHandle, direction.code)
120+
return true
121+
}
122+
}

decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/render/TaoComposeSceneHostLinux.kt

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,15 @@ internal class TaoComposeSceneHostLinux(
141141
private var heightPx: Int = 0
142142
private var scale: Float = 1f
143143

144+
/**
145+
* Peer-level resize hit-test, mirrors JBR's `WLDecoratedPeer` calling
146+
* `FrameDecoration.processMouseEvent` before `super.postMouseEvent`. Only
147+
* active for resizable (non-maximized, non-fullscreen) undecorated windows
148+
* — Tao on Linux always presents the toplevel as `decorations=false` and
149+
* paints chrome via Compose. See [onPointerMove] / [onPointerButton].
150+
*/
151+
private val resizeDecoration = ResizeFrameDecoration(window.handle)
152+
144153
// Coalescing: `onResized`/`onScaleFactorChanged` arrive at 60–120 Hz during
145154
// a user drag. Doing the X11 round-trip (XResizeWindow + rounded-shape
146155
// XShape rebuild) on every event is what was deadlocking the NVIDIA driver
@@ -918,6 +927,14 @@ internal class TaoComposeSceneHostLinux(
918927
val yPx = bFixed / 1024f
919928
lastPointerX = xPx
920929
lastPointerY = yPx
930+
931+
// JBR-style peer hook: hit-test the resize edge band BEFORE forwarding
932+
// the move to Compose. When the pointer is inside the band we set the
933+
// resize cursor and swallow the event so Compose's own cursor /
934+
// `PointerIcon` plumbing can't overwrite it on the next motion.
935+
val direction = currentResizeDirection(xPx, yPx)
936+
if (resizeDecoration.onMove(direction)) return
937+
921938
scene?.sendPointerEvent(
922939
eventType = PointerEventType.Move,
923940
position = Offset(xPx, yPx),
@@ -946,6 +963,15 @@ internal class TaoComposeSceneHostLinux(
946963
buttonCode: Int,
947964
pressed: Boolean,
948965
) {
966+
// JBR-style peer hook: a LMB press inside the resize band starts the
967+
// native resize drag and is NOT forwarded to Compose. Matches
968+
// `WLDecoratedPeer.postMouseEvent` calling
969+
// `FrameDecoration.processMouseEvent` first.
970+
if (pressed && buttonCode == dev.nucleusframework.window.tao.TaoMouseButton.LEFT) {
971+
val direction = currentResizeDirection(lastPointerX, lastPointerY)
972+
if (resizeDecoration.onLeftPress(direction)) return
973+
}
974+
949975
scene?.sendPointerEvent(
950976
eventType = if (pressed) PointerEventType.Press else PointerEventType.Release,
951977
position = Offset(lastPointerX, lastPointerY),
@@ -954,6 +980,27 @@ internal class TaoComposeSceneHostLinux(
954980
)
955981
}
956982

983+
/**
984+
* Hit-test the resize band at the given logical-pixel position. Returns
985+
* `null` (no resize) when the window is non-resizable, maximized, or
986+
* fullscreen — same gating as JBR's `peer.isInteractivelyResizable()`.
987+
*
988+
* [widthPx] / [heightPx] are physical pixels; we divide by [scale] to
989+
* compare against pointer coords (which the JNI bridge ships in logical
990+
* pixels, see [onPointerMove]).
991+
*/
992+
private fun currentResizeDirection(
993+
xLogical: Float,
994+
yLogical: Float,
995+
): ResizeFrameDecoration.Direction? {
996+
if (!window.isResizable) return null
997+
if (window.isFullscreen) return null
998+
if (window.isMaximized) return null
999+
val widthLogical = (widthPx / scale).toInt()
1000+
val heightLogical = (heightPx / scale).toInt()
1001+
return resizeDecoration.hitTest(xLogical, yLogical, widthLogical, heightLogical)
1002+
}
1003+
9571004
fun onPointerScroll(
9581005
dxAwt: Float,
9591006
dyAwt: Float,

decorated-window-tao/src/main/native/src/window_jni.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,43 @@ pub extern "system" fn Java_dev_nucleusframework_window_tao_NativeTaoBridge_nati
225225
}
226226
}
227227

228+
/// Begin an interactive resize drag in the given direction. Mirrors the JBR
229+
/// `WLDecoratedPeer.startResize(...)` peer-level hook: the Compose-side
230+
/// `ResizeFrameDecoration` hit-tests the edge band on `onPointerButton` and
231+
/// calls this BEFORE forwarding the event to `scene.sendPointerEvent`, so
232+
/// Compose never sees a click that was claimed by resize.
233+
///
234+
/// Direction encoding (matches Compose-side `ResizeDirection` ordinal):
235+
/// 0=N, 1=S, 2=E, 3=W, 4=NW, 5=NE, 6=SW, 7=SE.
236+
#[no_mangle]
237+
pub extern "system" fn Java_dev_nucleusframework_window_tao_NativeTaoBridge_nativeBeginResizeDrag(
238+
_env: JNIEnv,
239+
_class: JClass,
240+
handle: jlong,
241+
direction: jint,
242+
) {
243+
use tao::window::ResizeDirection;
244+
let dir = match direction {
245+
0 => ResizeDirection::North,
246+
1 => ResizeDirection::South,
247+
2 => ResizeDirection::East,
248+
3 => ResizeDirection::West,
249+
4 => ResizeDirection::NorthWest,
250+
5 => ResizeDirection::NorthEast,
251+
6 => ResizeDirection::SouthWest,
252+
7 => ResizeDirection::SouthEast,
253+
_ => return,
254+
};
255+
let guard = match WINDOWS.lock() {
256+
Ok(g) => g,
257+
Err(_) => return,
258+
};
259+
let Some(map) = guard.as_ref() else { return };
260+
if let Some(window) = map.get(&(handle as u64)) {
261+
let _ = window.drag_resize_window(dir);
262+
}
263+
}
264+
228265
#[no_mangle]
229266
pub extern "system" fn Java_dev_nucleusframework_window_tao_NativeTaoBridge_nativeIsMaximized(
230267
_env: JNIEnv,

0 commit comments

Comments
 (0)