Skip to content
Draft
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
4 changes: 2 additions & 2 deletions CLAUDE.md

Large diffs are not rendered by default.

170 changes: 150 additions & 20 deletions decorated-window-tao/api/decorated-window-tao.api

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package dev.nucleusframework.window.tao

import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.nucleusframework.window.styling.LocalDecoratedWindowStyle

/**
* What the [DockLayout] `splitter` slot composes in: which side and panel the
* splitter resizes, along which axis, and the modifier that makes an element
* the grip.
*/
public interface DockSplitterScope {
/** The side this splitter belongs to. */
public val side: DockSide

/**
* The axis the splitter is dragged along: [Orientation.Horizontal] for a
* bar between things side by side (a vertical line), [Orientation.Vertical]
* for a bar between things stacked.
*/
public val orientation: Orientation

/**
* The panel this splitter resizes: the layer just outside it on a layered
* side, or the panel just before it on a split side. `null` for the
* splitter between a split side's stack and the content, which drags the
* side's [SatelliteWorkspace.dockExtent].
*/
public val panel: SatelliteEntry?

/**
* Attaches the resize gesture and the resize cursor. Apply it to the
* element the user grabs; it may be larger than what is drawn — a 1 dp
* line can carry a wider invisible grip through `Modifier.requiredWidth`.
*/
public fun Modifier.dockSplitterHandle(): Modifier
}

/**
* The stock splitter: a bar of [DockSplitterThickness] in the window style's
* border colour, the whole of it the grip.
*/
@Composable
public fun DockSplitterScope.DefaultDockSplitter() {
val color = LocalDecoratedWindowStyle.current.colors.border
val sizeModifier =
if (orientation == Orientation.Horizontal) {
Modifier.fillMaxHeight().width(DockSplitterThickness)
} else {
Modifier.fillMaxWidth().height(DockSplitterThickness)
}
Box(sizeModifier.background(color).dockSplitterHandle())
}

/** Sign of a pointer delta that grows [side] towards the content. */
internal fun towardsContent(
side: DockSide,
deltaPx: Float,
): Float =
when (side) {
DockSide.Left, DockSide.Top -> deltaPx
DockSide.Right, DockSide.Bottom -> -deltaPx
}

internal class DockSplitterScopeImpl(
override val side: DockSide,
override val orientation: Orientation,
override val panel: SatelliteEntry?,
private val onDragPx: (deltaPx: Float, density: Density) -> Unit,
) : DockSplitterScope {
private val horizontal: Boolean get() = orientation == Orientation.Horizontal

override fun Modifier.dockSplitterHandle(): Modifier =
pointerHoverIcon(if (horizontal) TaoPointerIcons.ResizeEastWest else TaoPointerIcons.ResizeNorthSouth)
.pointerInput(this@DockSplitterScopeImpl) {
detectDragGestures { change, drag ->
change.consume()
onDragPx(if (horizontal) drag.x else drag.y, this)
}
}
}

/**
* Moves [deltaPx] of the stack's length from [after] to [before]: the
* divider follows the pointer one-to-one, and neither panel drops under
* [SatelliteWorkspace.MinDockExtent].
*/
internal fun moveWeight(
state: DockLayoutState,
side: DockSide,
before: SatelliteEntry,
after: SatelliteEntry,
deltaPx: Float,
density: Density,
) {
val lengthPx = state.stackLengthsPx[side]?.takeIf { it > 0 } ?: return
val total = state.panelsOn(side).sumOf { weightOf(it).toDouble() }.toFloat()
val pxPerWeight = lengthPx / total
val minWeight = with(density) { SatelliteWorkspace.MinDockExtent.toPx() } / pxPerWeight
val beforeWeight = weightOf(before)
val afterWeight = weightOf(after)
// Both panels already under the minimum — a stack too short for its
// panels — leaves nothing to move.
val low = minWeight - beforeWeight
val high = afterWeight - minWeight
if (low > high) return
val delta = (deltaPx / pxPerWeight).coerceIn(low, high)
if (delta == 0f || delta.isNaN()) return
state.workspace.setDockedWeight(before.id, beforeWeight + delta)
state.workspace.setDockedWeight(after.id, afterWeight - delta)
}

/** Thickness of the [DefaultDockSplitter] bar. */
public val DockSplitterThickness: Dp = 6.dp
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package dev.nucleusframework.window.tao

import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.draganddrop.dragAndDropTarget
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draganddrop.DragAndDropEvent
import androidx.compose.ui.draganddrop.DragAndDropTarget
import androidx.compose.ui.geometry.Offset
import dev.nucleusframework.window.tao.workspace.HostGeometry
import dev.nucleusframework.window.tao.workspace.positionInWindowPx

/**
* Makes the layout the drop target of a [SatelliteWorkspace.transferDrag]:
* the drag that rides the platform's DnD session where windows cannot be
* hit-tested from the source (native Wayland). The events arrive in this
* window's own coordinates, which is exactly what the source lacks, so the
* zone under the pointer is resolved here — previewed while hovering, recorded
* on the session at the drop for the source to act on when the session ends.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun Modifier.dockTransferTarget(
workspace: SatelliteWorkspace,
host: TaoWindow?,
geometry: HostGeometry?,
): Modifier {
if (host == null || geometry == null) return this
val target = remember(workspace, host, geometry) { DockTransferTarget(workspace, host, geometry) }
return dragAndDropTarget(
shouldStartDragAndDrop = { workspace.transferDrag != null },
target = target,
)
}

internal class DockTransferTarget(
private val workspace: SatelliteWorkspace,
private val host: TaoWindow,
private val geometry: HostGeometry,
) : DragAndDropTarget {
override fun onEntered(event: DragAndDropEvent) = preview(event)

override fun onMoved(event: DragAndDropEvent) = preview(event)

override fun onExited(event: DragAndDropEvent) = clearPreview()

override fun onEnded(event: DragAndDropEvent) = clearPreview()

override fun onDrop(event: DragAndDropEvent): Boolean {
val drag = workspace.transferDrag ?: return false
val position = event.positionInWindowPx()
val zone = zoneAt(position)?.let { workspace.targetFor(drag.entry, it) }
val outcome =
when {
zone != null && zone != drag.own -> TransferDrop.Dock(zone)
// Back onto its own side, or onto the very panel it came from:
// the gesture was abandoned, not a tear-out.
zone != null || drag.isOwnPanel(position) -> TransferDrop.Stay
else -> return false
}
drag.drop = outcome
clearPreview()
return true
}

/**
* The zone [positionInWindowPx] is in, resolved against the rectangles the
* layout draws ([HostGeometry.zoneBoundsInWindowPx]) so a drop lands where
* the highlight promised — inset behind existing layers included, at the
* rank of the stack the pointer is over — and against the layout's edges
* while none are published.
*/
internal fun zoneAt(positionInWindowPx: Offset): DockTarget? {
val zonePx = SatelliteWorkspace.DockZoneWidth.value * geometry.scaleOrOne()
val zones = geometry.zoneBoundsInWindowPx
if (zones.isEmpty()) {
return dockSideAt(geometry.layoutBoundsInWindowPx, positionInWindowPx, zonePx)?.let { DockTarget(host, it) }
}
// A stack the pointer is over wins over a strip running across its corner.
val (side, zone) =
zones.entries.firstOrNull { (_, zone) -> zone.slots.any { it.contains(positionInWindowPx) } }
?: zones.entries.firstOrNull { (_, zone) -> zone.strip.contains(positionInWindowPx) }
?: return null
return DockTarget(host, side, zone.slotAt(positionInWindowPx))
}

private fun preview(event: DragAndDropEvent) {
val drag = workspace.transferDrag ?: return
workspace.dockPreview =
zoneAt(event.positionInWindowPx())
?.let { workspace.targetFor(drag.entry, it) }
?.takeIf { it != drag.own }
}

private fun clearPreview() {
if (workspace.dockPreview?.host === host) workspace.dockPreview = null
}

/** Whether [positionInWindowPx] is on the dragged panel itself, in this host. */
private fun SatelliteTransferDrag.isOwnPanel(positionInWindowPx: Offset): Boolean =
(origin as? SatelliteDragOrigin.DockedPanel)?.host === host &&
entry.dockedBoundsInWindowPx?.contains(positionInWindowPx) == true
}
Loading
Loading