Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ Published releases are `2.4.x` (latest tag `v2.4.4`). Do not treat `IDEAL_API.md
- **KDoc on public API**: `UndocumentedPublicClass` / `UndocumentedPublicFunction` are enforced by detekt (`detekt` is wired into `check` / `preMerge`). Pre-existing gaps are grandfathered in per-module `<module>/detekt-baseline.xml` files — any *new* undocumented public class or function fails the build. Do not regenerate a baseline to silence a new finding; write the KDoc. `UndocumentedPublicProperty` stays off because the generated icon/symbol catalogs (`sf-symbols`, `freedesktop-icons`) would swamp it
- **Logging**: `java.util.logging` is the single facade for every runtime module — no SLF4J dependency forced on consumers, no raw `println` / `System.err` in `src/main`. Logger names must be the fully-qualified class name (or an explicit `dev.nucleusframework.*` string) so the whole framework sits under one JUL namespace. `allowNucleusRuntimeLogging = true` is an opt-in convenience that raises the `dev.nucleusframework` logger to `nucleusLoggingLevel` and attaches a colored console handler; apps that configure JUL themselves (`logging.properties`, `jul-to-slf4j`) leave it `false` and Nucleus never touches the JUL configuration
- `decorated-window-tao` is the recommended backend for new projects (no AWT, native event-loop-driven, true Windows fullscreen, GraalVM native-image first-class). `decorated-window-jni` and `decorated-window-jbr` (the AWT-based backends) are legacy/maintenance-only
- **macOS trackpad on Tao** (#652–#654): scroll deltas are AWT-shaped (`preciseWheelRotation`, no display scale). Trackpad gestures reach Compose as `PanStart` / `PanMove` / `PanEnd` (`panOffset` = AWT delta × 10 dp), wheel notches as `Scroll`; foundation's `Modifier.scrollable` handles both. Custom handlers that only listen for `PointerEventType.Scroll` must also handle Pan, or the app can set `-Dnucleus.tao.trackpadPanEvents=false` to get AWT-style `Scroll` for everything. Everything scroll-related enters the scene through `TaoSceneScrollRouter` (window + NSPanel popups); the phase wire (Rust `SCROLL_GESTURE_*`, `popup_panel.m`, `TaoScrollGesturePhase`) is guarded by `TaoScrollWireDriftTest`
- macOS Liquid Glass enabled by default via `macOsSdkVersion = "26.0"` (vtool SDK patching)
- The HotSpot GC is selected type-safely with `application { garbageCollector = GarbageCollector.Z }` (unset = JVM ergonomics). The flags are prepended to the launcher `.cfg` java-options and to the `run` task — before `jvmArgs`, so an explicit `-XX:+Use…GC` there still wins — and the AOT training run inherits them from the `.cfg`

Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,14 @@ otherwise AWT). Inside the block you can call `onDeepLink { }` and
`aotTraining()`; plugin-injected metadata is `NucleusApp`, not a generated
constants object.

On macOS the Tao backend delivers trackpad gestures to Compose as pan events
(`PointerEventType.PanStart` / `PanMove` / `PanEnd`, with `panOffset` in
pixels) and mouse-wheel notches as `Scroll`, with the same distances the AWT
backend produces. Foundation's `Modifier.scrollable` handles both; a custom
`pointerInput` that only reacts to `PointerEventType.Scroll` must also handle
pan, or start the app with `-Dnucleus.tao.trackpadPanEvents=false` to receive
AWT-style `Scroll` events for everything.

Then configure packaging in `build.gradle.kts`:

```kotlin
Expand Down
3 changes: 3 additions & 0 deletions decorated-window-tao/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ val taoHeadfulTest by tasks.registering(JavaExec::class) {
// Unattended: a fatal must fail the suite loudly, not block in the #622
// native dialog until the global watchdog halts and eats the real result.
systemProperty("nucleus.tao.fatalErrorDialog", "false")
// Arms the macOS scrollWheel: injector (nativeDiagInjectScrollWheel) the
// trackpad cases drive; it is inert in any process without this variable.
environment("NUCLEUS_TAO_INPUT_INJECTION", "1")
// Same Kover JVM agent the `test` task uses, so headful window coverage
// is counted. JavaExec is otherwise invisible to Kover.
dependsOn(tasks.named("koverFindJar"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.PointerButton
Expand Down Expand Up @@ -274,6 +275,30 @@ private fun Modifier.nativeViewPointerInterop(
)
true
}
// Trackpad pan (#654): the whole gesture belongs to the
// native view — begin and end included, so its own
// scroll view finishes rubber-banding / fades its
// scrollers — and is consumed so the Compose scrollable
// above never opens a pan session of its own. The
// offset stays in scene px; the host converts it back
// to wheel units with the scale the router used.
PointerEventType.PanStart,
PointerEventType.PanMove,
PointerEventType.PanEnd,
-> {
host.dispatchPanToNative(
handle,
xPx,
yPx,
change.panOffset,
when (event.type) {
PointerEventType.PanStart -> TaoNativeViewHost.PAN_START
PointerEventType.PanEnd -> TaoNativeViewHost.PAN_END
else -> TaoNativeViewHost.PAN_MOVE
},
)
true
}
else -> false
}
if (dispatched) event.changes.forEach { it.consume() }
Expand Down Expand Up @@ -330,7 +355,7 @@ internal interface TaoNativeViewHost {
) {
}

/** Forwards an unconsumed Compose scroll onto the native view. */
/** Forwards an unconsumed Compose scroll (AWT wheel units) onto the native view. */
fun dispatchScrollToNative(
handle: Long,
xPx: Float,
Expand All @@ -340,6 +365,32 @@ internal interface TaoNativeViewHost {
) {
}

/**
* Forwards one step of an unconsumed trackpad pan onto the native view
* (#654). [panOffsetPx] is Compose's `panOffset` in scene px; the host
* converts it back to wheel units with the same scale the scroll router
* sized it with, so an app-level `LocalDensity` override cannot skew it.
* [phase] is [PAN_START], [PAN_MOVE] or [PAN_END], so the native side can
* hand the embedded view a gesture with a proper begin and end. macOS
* only: the other backends never produce Pan events.
*/
fun dispatchPanToNative(
handle: Long,
xPx: Float,
yPx: Float,
panOffsetPx: Offset,
phase: Int,
) {
}

companion object {
/** Mouse-wheel notch / phase-less precise scroll (`native_view.m` `kNvScrollWheel`). */
const val SCROLL_WHEEL: Int = 0
const val PAN_START: Int = 1
const val PAN_MOVE: Int = 2
const val PAN_END: Int = 3
}

/**
* Marks that the in-flight pointer Press was handed to a native
* view (so the host must not steal first-responder back).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,15 @@ public object TaoApplication {
guarded { lookup(handle)?.dispatchTrackpadGesture(kind, phase, xFixed, yFixed, valueFixed) }
}

override fun onScrollGesture(
handle: Long,
phase: Int,
dxFixed: Int,
dyFixed: Int,
) {
guarded { lookup(handle)?.dispatchScrollGesture(phase, dxFixed, dyFixed) }
}

override fun onTouchInput(
handle: Long,
phase: Int,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,45 @@ public object TaoTrackpadPhase {
public const val CANCELLED: Int = 3
}

/**
* Phase of a macOS trackpad scroll gesture step as delivered by
* `EventCallback.onScrollGesture` (#654). AppKit reports the fingers-on-glass
* part in `NSEvent.phase` and the inertial tail that follows in
* `momentumPhase`, never both at once. [wire] is the code the Rust loop
* (`events.rs` `SCROLL_GESTURE_*`) and the popup panel (`popup_panel.m`
* `NucleusScrollGesture*`) send; a scroll that belongs to no gesture (wheel
* notch, phase-less device) has no phase — `null` on the JVM,
* [NONE_WIRE] on the popup wire. Distinct from the public
* [TaoTrackpadPhase] of magnify / rotate gestures on purpose: the two streams
* are different and must not be passed for one another.
*/
@Suppress("MagicNumber")
internal enum class TaoScrollGesturePhase(
val wire: Int,
) {
BEGAN(0),
CHANGED(1),
ENDED(2),
CANCELLED(3),
MOMENTUM_BEGAN(4),
MOMENTUM_CHANGED(5),
MOMENTUM_ENDED(6),

/** Fingers touched the trackpad, no scroll yet (`NSEventPhaseMayBegin`). */
MAY_BEGIN(7),
;

companion object {
/** Wire code for "not a gesture step" (only the popup wire carries it). */
const val NONE_WIRE: Int = -1

private val byWire: Map<Int, TaoScrollGesturePhase> = entries.associateBy { it.wire }

/** `null` for [NONE_WIRE] and for any code this build does not know. */
fun fromWire(code: Int): TaoScrollGesturePhase? = byWire[code]
}
}

/** Modifier-state bitmask that mirrors the Rust side. */
@Suppress("MagicNumber")
public object TaoModifierMask {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicBoolean
import java.util.logging.Logger
import dev.nucleusframework.window.tao.event.AWT_PIXEL_TO_ROTATION as SHARED_AWT_PIXEL_TO_ROTATION
import dev.nucleusframework.window.tao.event.MACOS_AWT_SCROLL_AMOUNT as SHARED_MACOS_AWT_SCROLL_AMOUNT

/**
* Phase 2 handle to a window owned by the Tao event loop.
Expand Down Expand Up @@ -1085,6 +1087,42 @@ public class TaoWindow internal constructor(
trackpadGestureListener?.onGesture(kind, phase, xFixed, yFixed, valueFixed)
}

/**
* macOS trackpad scroll gesture (#654) — see
* [NativeTaoBridge.EventCallback.onScrollGesture]. Shaped exactly like
* [TaoEventCode.SCROLL_PIXEL] (AWT `preciseWheelRotation`, so one unit is
* `10.dp` of pan for Compose) with the gesture [phase] attached; the
* scene host turns the stream into Compose Pan events.
*/
internal fun dispatchScrollGesture(
phaseWire: Int,
dxFixed: Int,
dyFixed: Int,
) {
// A code this build does not know degrades to a plain precise scroll
// rather than a pan step the router cannot place.
val phase = TaoScrollGesturePhase.fromWire(phaseWire)
pointerScrollListener?.invoke(preciseScrollEvent(dxFixed, dyFixed, gesturePhase = phase))
}

/**
* AWT's macOS NSEvent → MouseWheelEvent conversion: `preciseWheelRotation
* = -scrollingDelta / 10`, no display scale (#652 / #653). The wire carries
* LOGICAL AppKit points × [SCROLL_FIXED_SCALE]; tao (and AppKit) count
* positive as "content moves down / right", AWT as "scroll down / right",
* hence the negation on both axes.
*/
private fun preciseScrollEvent(
dxFixed: Int,
dyFixed: Int,
gesturePhase: TaoScrollGesturePhase?,
) = TaoPointerScrollEvent(
dxAwt = -(dxFixed / SCROLL_FIXED_SCALE) / AWT_PIXEL_TO_ROTATION,
dyAwt = -(dyFixed / SCROLL_FIXED_SCALE) / AWT_PIXEL_TO_ROTATION,
scrollAmount = MACOS_AWT_SCROLL_AMOUNT,
gesturePhase = gesturePhase,
)

internal fun dispatchKey(
type: Int,
vkCode: Int,
Expand Down Expand Up @@ -1202,6 +1240,9 @@ public class TaoWindow internal constructor(
TaoEventCode.SHOWN -> shownListener?.invoke()
TaoEventCode.SIZE_MOVE -> sizeMoveListener?.invoke(a != 0)
TaoEventCode.SCROLL_LINE -> {
// tao (and AppKit) count positive as "content moves down /
// right"; AWT counts positive as "scroll down / right", hence
// the negation on both axes.
// AWT sends the wheel rotation as scrollDelta and leaves the
// platform line-count policy in MouseWheelEvent.scrollAmount.
// The Windows backend emits the raw notch count (1.0 per notch,
Expand All @@ -1222,18 +1263,9 @@ public class TaoWindow internal constructor(
)
}
TaoEventCode.SCROLL_PIXEL -> {
// AWT's macOS NSEvent → MouseWheelEvent conversion divides
// scrollingDelta by ~10 to obtain preciseWheelRotation; we mirror it.
// Negate as above for the AWT sign convention.
val dx = -(a / SCROLL_FIXED_SCALE) / AWT_PIXEL_TO_ROTATION
val dy = -(b / SCROLL_FIXED_SCALE) / AWT_PIXEL_TO_ROTATION
pointerScrollListener?.invoke(
TaoPointerScrollEvent(
dxAwt = dx,
dyAwt = dy,
scrollAmount = MACOS_AWT_SCROLL_AMOUNT,
),
)
// Precise scroll outside a gesture (smooth-scroll mice); see
// [preciseScrollEvent] for the AWT shaping.
pointerScrollListener?.invoke(preciseScrollEvent(a, b, gesturePhase = null))
}
// KEY_DOWN / KEY_UP: routed in Phase 2b (no logical-key encoding yet)
}
Expand All @@ -1242,8 +1274,13 @@ public class TaoWindow internal constructor(
private companion object {
const val SCROLL_FIXED_SCALE: Float = 100f
const val LINUX_AWT_SCROLL_AMOUNT_DEFAULT: Int = 3
const val MACOS_AWT_SCROLL_AMOUNT: Int = 1
const val AWT_PIXEL_TO_ROTATION: Float = 10f

// ABI: a `const val` in a private companion still compiles to a public
// static on TaoWindow, and these two are part of the validated 2.4.x
// surface (api/decorated-window-tao.api). Aliases of the shared
// definitions in event/MacOsWheelDelta.kt so they cannot diverge.
const val AWT_PIXEL_TO_ROTATION: Float = SHARED_AWT_PIXEL_TO_ROTATION
const val MACOS_AWT_SCROLL_AMOUNT: Int = SHARED_MACOS_AWT_SCROLL_AMOUNT
const val WINDOWS_TOUCH_DRAG_THRESHOLD_PX: Int = 16

val platformLineScrollAmount: Int
Expand All @@ -1260,10 +1297,19 @@ public class TaoWindow internal constructor(
}
}

/**
* One wheel / trackpad scroll step, shaped like AWT's `MouseWheelEvent`:
* [dxAwt] / [dyAwt] are `preciseWheelRotation` (positive = scroll down /
* right), [scrollAmount] the platform line-count policy Compose Desktop reads.
* [gesturePhase] is the [TaoScrollGesturePhase] of a macOS trackpad gesture
* step, or `null` for a wheel notch / phase-less device — gesture steps
* become Compose Pan events, the rest ordinary Scroll events.
*/
internal data class TaoPointerScrollEvent(
val dxAwt: Float,
val dyAwt: Float,
val scrollAmount: Int,
val gesturePhase: TaoScrollGesturePhase? = null,
)

private data class WindowsTitleBarTouchDrag(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package dev.nucleusframework.window.tao.event

import androidx.compose.ui.geometry.Offset
import dev.nucleusframework.window.tao.TaoPointerScrollEvent
import dev.nucleusframework.window.tao.TaoScrollGesturePhase

/** Same factor [dev.nucleusframework.window.tao.TaoWindow] uses on `SCROLL_PIXEL`. */
internal const val AWT_PIXEL_TO_ROTATION: Float = 10f
Expand All @@ -10,40 +11,48 @@ internal const val AWT_PIXEL_TO_ROTATION: Float = 10f
internal const val MACOS_AWT_SCROLL_AMOUNT: Int = 1

/**
* Maps raw AppKit `scrollingDelta*` onto AWT `preciseWheelRotation`.
* Maps raw AppKit `scrollingDelta*` onto AWT `preciseWheelRotation` the way
* OpenJDK's `AWTView.m` + `CPlatformResponder` do: `-[event deltaX/Y]`, where
* a precise (trackpad) event's legacy delta is `scrollingDelta × 0.1` in
* points. AppKit's sign is "positive = content moves down / right", AWT's is
* "positive = scroll down / right" — both axes flip (#652) — and the display
* scale never enters (#653).
*
* Matches [dev.nucleusframework.window.tao.TaoWindow] `SCROLL_LINE` /
* `SCROLL_PIXEL`: tao already flips X then Kotlin negates both axes, so
* the net sign from raw AppKit is `Offset(dx, -dy)`. Precise (trackpad)
* deltas are converted to physical pixels then divided by 10, same as
* AWT's NSEvent → `preciseWheelRotation` conversion.
*
* Popup NSPanel content views skip tao and must go through this before
* Compose.
* Same net result as [dev.nucleusframework.window.tao.TaoWindow] `SCROLL_LINE`
* / `SCROLL_PIXEL`. Popup NSPanel content views skip tao and must go through
* this before Compose.
*/
internal fun appKitWheelToAwtScrollDelta(
dx: Float,
dy: Float,
precise: Boolean,
scale: Float,
): Offset {
val awtSign = Offset(dx, -dy)
return if (precise) awtSign * (scale / AWT_PIXEL_TO_ROTATION) else awtSign
val awtSign = Offset(-dx, -dy)
return if (precise) awtSign / AWT_PIXEL_TO_ROTATION else awtSign
}

/**
* [gesturePhaseWire] is the [TaoScrollGesturePhase.wire] of a trackpad step,
* [TaoScrollGesturePhase.NONE_WIRE] for a wheel notch.
*/
internal fun appKitWheelToAwtScrollEvent(
dx: Float,
dy: Float,
precise: Boolean,
scale: Float,
gesturePhaseWire: Int = TaoScrollGesturePhase.NONE_WIRE,
): TaoPointerScrollEvent {
val delta = appKitWheelToAwtScrollDelta(dx, dy, precise, scale)
val delta = appKitWheelToAwtScrollDelta(dx, dy, precise)
return TaoPointerScrollEvent(
dxAwt = delta.x,
dyAwt = delta.y,
// 1, like TaoWindow / Windows: MacOSCocoaConfig does not read a
// lines-per-notch multiplier out of scrollAmount the way LinuxGtkConfig
// does. Do not copy LINUX_AWT_SCROLL_AMOUNT_DEFAULT here.
scrollAmount = MACOS_AWT_SCROLL_AMOUNT,
// The phase, not the precision flag, says whether this step belongs to
// a gesture: AppKit has been seen reporting a zero-delta terminal step
// with hasPreciseScrollingDeltas == NO, and dropping its phase would
// close the pan mid-gesture (the Rust window path routes the same way).
gesturePhase = TaoScrollGesturePhase.fromWire(gesturePhaseWire),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,31 @@ internal object TaoSyntheticMouseWheelEvent {
(if (isMetaPressed) InputEvent.META_DOWN_MASK else 0)
}

/**
* Feeds one step of a trackpad pan into the scene as Compose's `PanStart` /
* `PanMove` / `PanEnd` (#654). [panOffset] is in pixels with Compose's sign
* (positive = content scrolls down / right, like `scrollDelta`); foundation's
* `TrackpadScrollingLogic` consumes it directly, so unlike wheel scrolls no
* AWT-shaped native event is attached — `ScrollConfig` is only consulted for
* `Scroll` events.
*/
@OptIn(InternalComposeUiApi::class)
internal fun ComposeScene.dispatchTrackpadPan(
x: Float,
y: Float,
type: PointerEventType,
panOffset: Offset,
keyboardModifiers: PointerKeyboardModifiers = PointerKeyboardModifiers(),
) {
sendPointerEvent(
eventType = type,
position = Offset(x, y),
type = PointerType.Mouse,
keyboardModifiers = keyboardModifiers,
panGestureOffset = panOffset,
)
}

/**
* Feeds an already AWT-shaped [TaoPointerScrollEvent] into the scene, including
* the synthetic `MouseWheelEvent` Compose Desktop's scroll config reads for
Expand Down
Loading
Loading