diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/BoardCanvas.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/BoardCanvas.kt index 89d89d33..b7bc0bcf 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/BoardCanvas.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/BoardCanvas.kt @@ -80,6 +80,7 @@ fun BoardCanvas( onDetachConnection: (Connection, Boolean, Offset) -> Unit, onConnectionDrag: (Offset) -> Unit, onConnectionDrop: (Boolean) -> Unit, + onConnectionCancel: () -> Unit, onMoveConnectionFirst: (Connection) -> Unit, onMoveConnectionLast: (Connection) -> Unit, selectedNodeIds: Set, @@ -173,6 +174,7 @@ fun BoardCanvas( onZoom = onZoom, onConnectionDrag = onConnectionDrag, onConnectionDrop = onConnectionDrop, + onConnectionCancel = onConnectionCancel, onDeleteConnection = onDeleteConnection, onDetachConnection = onDetachConnection ) diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/FlowEditorView.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/FlowEditorView.kt index e41e6c18..e69ed27b 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/FlowEditorView.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/FlowEditorView.kt @@ -55,6 +55,7 @@ import org.wip.plugintoolkit.api.isCompatibleWith import org.wip.plugintoolkit.core.notification.NotificationService import org.wip.plugintoolkit.core.theme.ToolkitTheme import org.wip.plugintoolkit.features.flows.model.Flow +import org.wip.plugintoolkit.features.flows.model.Connection import org.wip.plugintoolkit.features.flows.model.Node import org.wip.plugintoolkit.features.flows.model.NodeSerializationUtils import org.wip.plugintoolkit.features.flows.viewmodel.FlowEditorViewModel @@ -154,6 +155,7 @@ fun FlowEditorView( var dragStartPosition by remember { mutableStateOf(Offset.Zero) } var dragGrabOffset by remember { mutableStateOf(Offset.Zero) } var draggingNodeScale by remember { mutableStateOf(1f) } + var connectionBeingRewired by remember { mutableStateOf(null) } val handlePaletteClick = { paletteNode: PaletteNode -> val dropPos = (Offset(boardSize.width / 2f, boardSize.height / 2f) - state.offset) / state.scale @@ -213,7 +215,7 @@ fun FlowEditorView( onBoardSizeChanged = { boardSize = it }, onDeleteConnection = { viewModel.onEvent(FlowEvent.DeleteConnection(it)) }, onDetachConnection = { connection, isSource, offset -> - viewModel.onEvent(FlowEvent.DeleteConnection(connection)) + connectionBeingRewired = connection if (isSource) { connectionStartNodeId = connection.targetNodeId connectionStartPortId = connection.targetPortId @@ -245,17 +247,38 @@ fun FlowEditorView( val targetPortId = if (connectionStartIsOutput) highlightedPortId!! else connectionStartPortId!! + val original = connectionBeingRewired viewModel.onEvent( - FlowEvent.TryConnectPorts( - sourceNodeId, - sourcePortId, - targetNodeId, - targetPortId, - isShiftPressed - ) + if (original != null) { + FlowEvent.RewireConnection( + original, + sourceNodeId, + sourcePortId, + targetNodeId, + targetPortId, + isShiftPressed + ) + } else { + FlowEvent.TryConnectPorts( + sourceNodeId, + sourcePortId, + targetNodeId, + targetPortId, + isShiftPressed + ) + } ) } isDrawingConnection = false + connectionBeingRewired = null + connectionStartNodeId = null + connectionStartPortId = null + highlightedPortId = null + highlightedNodeId = null + }, + onConnectionCancel = { + isDrawingConnection = false + connectionBeingRewired = null connectionStartNodeId = null connectionStartPortId = null highlightedPortId = null @@ -708,7 +731,8 @@ fun FlowEditorView( pendingConn.sourceNodeId, pendingConn.sourcePortId, pendingConn.targetNodeId, - pendingConn.targetPortId + pendingConn.targetPortId, + pendingConn.originalConnection ) ) viewModel.onEvent(FlowEvent.CancelPendingConnection) diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/FlowTestHelpers.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/FlowTestHelpers.kt index 34bf0c5e..06836111 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/FlowTestHelpers.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/FlowTestHelpers.kt @@ -38,6 +38,7 @@ fun RenderTestBoardCanvas( onDetachConnection = { _, _, _ -> }, onConnectionDrag = {}, onConnectionDrop = {}, + onConnectionCancel = {}, onMoveConnectionFirst = {}, onMoveConnectionLast = {}, selectedNodeIds = selectedNodeIds, diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/canvas/BoardGestureModifiers.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/canvas/BoardGestureModifiers.kt index bd6dca1a..376521ff 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/canvas/BoardGestureModifiers.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/canvas/BoardGestureModifiers.kt @@ -2,6 +2,9 @@ package org.wip.plugintoolkit.features.flows.ui.canvas import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.geometry.Offset @@ -145,6 +148,7 @@ fun Modifier.boardSelectionBoxGesture( ) } +@Composable fun Modifier.boardPointerEventGesture( interactionState: BoardInteractionState, isDrawingConnection: Boolean, @@ -156,9 +160,17 @@ fun Modifier.boardPointerEventGesture( onZoom: (Float, Offset, Boolean) -> Unit, onConnectionDrag: (Offset) -> Unit, onConnectionDrop: (Boolean) -> Unit, + onConnectionCancel: () -> Unit, onDeleteConnection: (Connection) -> Unit, onDetachConnection: (Connection, Boolean, Offset) -> Unit -): Modifier = this.pointerInput(connections, nodes, scale, offset, isDrawingConnection) { +): Modifier { + val currentIsDrawingConnection by rememberUpdatedState(isDrawingConnection) + val currentOnConnectionDrag by rememberUpdatedState(onConnectionDrag) + val currentOnConnectionDrop by rememberUpdatedState(onConnectionDrop) + val currentOnConnectionCancel by rememberUpdatedState(onConnectionCancel) + val currentOnDetachConnection by rememberUpdatedState(onDetachConnection) + + return this.pointerInput(connections, nodes, scale, offset) { awaitPointerEventScope { while (true) { val event = awaitPointerEvent() @@ -173,9 +185,9 @@ fun Modifier.boardPointerEventGesture( } } else if (event.type == PointerEventType.Move) { interactionState.lastPointerPosition = position - if (isDrawingConnection) { + if (currentIsDrawingConnection) { val boardPos = (position - offset) / scale - onConnectionDrag(boardPos) + currentOnConnectionDrag(boardPos) } var bestConnection: Connection? = null @@ -241,22 +253,28 @@ fun Modifier.boardPointerEventGesture( } else if (event.keyboardModifiers.isCtrlPressed) { interactionState.hoveredConnection?.let { conn -> val isSrc = interactionState.hoveredConnectionIsSource ?: false - onDetachConnection(conn, isSrc, position) + currentOnDetachConnection(conn, isSrc, position) event.changes.forEach { it.consume() } - while (true) { - val dragEvent = awaitPointerEvent() - if (dragEvent.type == PointerEventType.Move) { - val screenPos = dragEvent.changes.firstOrNull()?.position ?: Offset.Zero - interactionState.lastPointerPosition = screenPos - val boardPos = (screenPos - offset) / scale - onConnectionDrag(boardPos) - dragEvent.changes.forEach { it.consume() } - } else if (dragEvent.type == PointerEventType.Release) { - onConnectionDrop(dragEvent.keyboardModifiers.isShiftPressed) - break + var completed = false + try { + while (true) { + val dragEvent = awaitPointerEvent() + if (dragEvent.type == PointerEventType.Move) { + val screenPos = dragEvent.changes.firstOrNull()?.position ?: Offset.Zero + interactionState.lastPointerPosition = screenPos + val boardPos = (screenPos - offset) / scale + currentOnConnectionDrag(boardPos) + dragEvent.changes.forEach { it.consume() } + } else if (dragEvent.type == PointerEventType.Release) { + currentOnConnectionDrop(dragEvent.keyboardModifiers.isShiftPressed) + completed = true + break + } } + } finally { + if (!completed) currentOnConnectionCancel() } } } @@ -264,3 +282,4 @@ fun Modifier.boardPointerEventGesture( } } } +} diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/viewmodel/FlowConnectionManager.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/viewmodel/FlowConnectionManager.kt index 5d6183d5..2e561c07 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/viewmodel/FlowConnectionManager.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/viewmodel/FlowConnectionManager.kt @@ -41,21 +41,24 @@ class FlowConnectionManager( if (sourcePort == null || targetPort == null) return currentState - val isTypeAllowed = sourcePort.dataType.isCompatibleWith(targetPort.dataType) || - sourcePort.dataType.canConvert(targetPort.dataType) + val sourceType = currentState.inferredTypes[sourceNodeId to sourcePortId] ?: sourcePort.dataType + val targetType = currentState.inferredTypes[targetNodeId to targetPortId] ?: targetPort.dataType + val isTypeAllowed = sourceType.isCompatibleWith(targetType) if (!isTypeAllowed) { return currentState } + val sourceSemantics = currentState.inferredSemanticTypes[sourceNodeId to sourcePortId] ?: sourcePort.semanticTypes + val targetSemantics = currentState.inferredSemanticTypes[targetNodeId to targetPortId] ?: targetPort.semanticTypes val semanticCheck = - org.wip.plugintoolkit.api.checkSemanticCompatibility(sourcePort.semanticTypes, targetPort.semanticTypes) + org.wip.plugintoolkit.api.checkSemanticCompatibility(sourceSemantics, targetSemantics) if (semanticCheck is org.wip.plugintoolkit.api.CompatibilityResult.Incompatible) { return currentState } else if (semanticCheck is org.wip.plugintoolkit.api.CompatibilityResult.Warning) { notificationService?.toast("Warning: ${semanticCheck.message}") } - val isList = targetPort.dataType is DataType.Array + val isList = targetType is DataType.Array val filteredConnections = if (isList) { currentState.flow.connections } else { @@ -175,12 +178,18 @@ class FlowConnectionManager( sourceNodeId: Long, sourcePortId: String, targetNodeId: Long, - targetPortId: String + targetPortId: String, + originalConnection: Connection? = null ): FlowEditorState { if (sourceNodeId == targetNodeId) return currentState + if (originalConnection != null && originalConnection !in currentState.flow.connections) return currentState - val sourceNode = currentState.flow.nodes.find { it.id == sourceNodeId } - val targetNode = currentState.flow.nodes.find { it.id == targetNodeId } + val validationState = originalConnection?.let { + prepareRewireBaseState(currentState, it, targetNodeId, targetPortId) + } ?: currentState + + val sourceNode = validationState.flow.nodes.find { it.id == sourceNodeId } + val targetNode = validationState.flow.nodes.find { it.id == targetNodeId } if (sourceNode == null || targetNode == null) return currentState @@ -189,6 +198,31 @@ class FlowConnectionManager( if (sourcePort == null || targetPort == null) return currentState + val sourceType = validationState.inferredTypes[sourceNodeId to sourcePortId] ?: sourcePort.dataType + val targetType = validationState.inferredTypes[targetNodeId to targetPortId] ?: targetPort.dataType + val sourceSemantics = validationState.inferredSemanticTypes[sourceNodeId to sourcePortId] ?: sourcePort.semanticTypes + val targetSemantics = validationState.inferredSemanticTypes[targetNodeId to targetPortId] ?: targetPort.semanticTypes + if (!sourceType.canConvert(targetType)) return currentState + if (org.wip.plugintoolkit.api.checkSemanticCompatibility(sourceSemantics, targetSemantics) + is org.wip.plugintoolkit.api.CompatibilityResult.Incompatible + ) return currentState + + val baseState = validationState + val isListTarget = targetType is DataType.Array + val filteredConnections = if (isListTarget) { + baseState.flow.connections + } else { + baseState.flow.connections.filterNot { + it.targetNodeId == targetNodeId && it.targetPortId == targetPortId + } + } + if (org.wip.plugintoolkit.features.flows.logic.FlowCycleDetector.wouldCreateCycle( + sourceNodeId, + targetNodeId, + filteredConnections + ) + ) return currentState + var midPosition = Offset( (sourceNode.position.x + targetNode.position.x) / 2f, (sourceNode.position.y + targetNode.position.y) / 2f @@ -209,15 +243,29 @@ class FlowConnectionManager( ) val conn1 = Connection(sourceNodeId, sourcePortId, convertNode.id, "input_data") - val conn2 = Connection(convertNode.id, "output_data", targetNodeId, targetPortId) - - val filteredConnections = currentState.flow.connections.filterNot { - it.targetNodeId == targetNodeId && it.targetPortId == targetPortId - } + val preferredOrder = originalConnection + ?.takeIf { it.targetNodeId == targetNodeId && it.targetPortId == targetPortId } + ?.orderIndex + val targetOrder = if (isListTarget) { + preferredOrder?.coerceIn(0, filteredConnections.count { + it.targetNodeId == targetNodeId && it.targetPortId == targetPortId + }) ?: filteredConnections.count { + it.targetNodeId == targetNodeId && it.targetPortId == targetPortId + } + } else null + val shiftedConnections = if (isListTarget && preferredOrder != null) { + val insertionOrder = targetOrder ?: 0 + filteredConnections.map { connection -> + if (connection.targetNodeId == targetNodeId && connection.targetPortId == targetPortId && + (connection.orderIndex ?: 0) >= insertionOrder + ) connection.copy(orderIndex = (connection.orderIndex ?: 0) + 1) else connection + } + } else filteredConnections + val conn2 = Connection(convertNode.id, "output_data", targetNodeId, targetPortId, targetOrder) val newFlow = currentState.flow.copy( - nodes = currentState.flow.nodes + convertNode, - connections = filteredConnections + conn1 + conn2 + nodes = baseState.flow.nodes + convertNode, + connections = shiftedConnections + conn1 + conn2 ) return currentState.copy( flow = newFlow, @@ -242,6 +290,166 @@ class FlowConnectionManager( ) } + /** + * Replaces one endpoint without exposing an intermediate disconnected state. If the new + * endpoints are invalid, incompatible, or cyclic, the original connection is preserved. + */ + fun handleRewireConnection( + currentState: FlowEditorState, + original: Connection, + sourceNodeId: Long, + sourcePortId: String, + targetNodeId: Long, + targetPortId: String, + isShiftPressed: Boolean + ): FlowEditorState { + if (original !in currentState.flow.connections) return currentState + if ( + original.sourceNodeId == sourceNodeId && original.sourcePortId == sourcePortId && + original.targetNodeId == targetNodeId && original.targetPortId == targetPortId + ) return currentState + + if (sourceNodeId == targetNodeId) { + viewModelScope.launch { + notificationService?.toast(getString(Res.string.flow_editor_same_node_warning)) + } + return currentState + } + + val validationState = prepareRewireBaseState(currentState, original, targetNodeId, targetPortId) + val sourceNode = validationState.flow.nodes.find { it.id == sourceNodeId } ?: return currentState + val targetNode = validationState.flow.nodes.find { it.id == targetNodeId } ?: return currentState + val sourcePort = sourceNode.outputs.find { it.id == sourcePortId } ?: return currentState + val targetPort = targetNode.inputs.find { it.id == targetPortId } ?: return currentState + val sourceType = validationState.inferredTypes[sourceNodeId to sourcePortId] ?: sourcePort.dataType + val targetType = validationState.inferredTypes[targetNodeId to targetPortId] ?: targetPort.dataType + val sourceSemantics = validationState.inferredSemanticTypes[sourceNodeId to sourcePortId] ?: sourcePort.semanticTypes + val targetSemantics = validationState.inferredSemanticTypes[targetNodeId to targetPortId] ?: targetPort.semanticTypes + val semantics = org.wip.plugintoolkit.api.checkSemanticCompatibility(sourceSemantics, targetSemantics) + + if (sourceType.isCompatibleWith(targetType) && + semantics !is org.wip.plugintoolkit.api.CompatibilityResult.Incompatible + ) { + var rewired = handleConnectPorts( + validationState, + sourceNodeId, + sourcePortId, + targetNodeId, + targetPortId + ) + val matchingBefore = validationState.flow.connections.count { + it.sourceNodeId == sourceNodeId && it.sourcePortId == sourcePortId && + it.targetNodeId == targetNodeId && it.targetPortId == targetPortId + } + val matchingAfter = rewired.flow.connections.count { + it.sourceNodeId == sourceNodeId && it.sourcePortId == sourcePortId && + it.targetNodeId == targetNodeId && it.targetPortId == targetPortId + } + if (matchingAfter != matchingBefore + 1) return currentState + + if (targetType is DataType.Array && + original.targetNodeId == targetNodeId && original.targetPortId == targetPortId + ) { + val replacement = rewired.flow.connections.lastOrNull { + it.sourceNodeId == sourceNodeId && it.sourcePortId == sourcePortId && + it.targetNodeId == targetNodeId && it.targetPortId == targetPortId + } ?: return currentState + rewired = handleUpdateConnectionOrder( + rewired, + replacement, + (original.orderIndex ?: 0).coerceIn( + 0, + rewired.flow.connections.count { + it.targetNodeId == targetNodeId && it.targetPortId == targetPortId + } - 1 + ) + ) + } + return rewired + } + + if (semantics !is org.wip.plugintoolkit.api.CompatibilityResult.Incompatible && + sourceType.canConvert(targetType) + ) { + return if (isShiftPressed) { + handleAutoConvertAndConnect( + currentState, + sourceNodeId, + sourcePortId, + targetNodeId, + targetPortId, + original + ) + } else { + currentState.copy( + pendingConnection = PendingConnection( + sourceNodeId, + sourcePortId, + targetNodeId, + targetPortId, + sourceType, + targetType, + original + ) + ) + } + } + + viewModelScope.launch { + val message = if (!sourceType.isCompatibleWith(targetType)) { + org.wip.plugintoolkit.core.model.LocalizedString.ResourceWithArgs( + Res.string.flow_editor_incompatible_types, + listOf(sourceType.format(), targetType.format()) + ) + } else { + org.wip.plugintoolkit.core.model.LocalizedString.ResourceWithArgs( + Res.string.flow_editor_incompatible_semantics, + listOf( + sourceSemantics.joinToString { it.canonicalId }, + targetSemantics.joinToString { it.canonicalId } + ) + ) + } + notificationService?.toast(message) + } + return currentState + } + + /** + * Builds the graph that will exist immediately before a rewired edge is attached, then + * recalculates inference from that graph. This prevents wildcard ports from retaining types + * or semantics contributed by the edge being replaced (or by an occupied scalar target). + */ + private fun prepareRewireBaseState( + currentState: FlowEditorState, + original: Connection, + targetNodeId: Long, + targetPortId: String + ): FlowEditorState { + val withoutOriginal = handleDeleteConnection(currentState, original) + val targetPort = currentState.flow.nodes.find { it.id == targetNodeId } + ?.inputs + ?.find { it.id == targetPortId } + ?: return withoutOriginal + val currentTargetType = currentState.inferredTypes[targetNodeId to targetPortId] ?: targetPort.dataType + val candidate = if (currentTargetType is DataType.Array) { + withoutOriginal + } else { + withoutOriginal.copy( + flow = withoutOriginal.flow.copy( + connections = withoutOriginal.flow.connections.filterNot { + it.targetNodeId == targetNodeId && it.targetPortId == targetPortId + } + ) + ) + } + val inference = org.wip.plugintoolkit.features.flows.logic.FlowTypeInference.runTypeInference(candidate.flow) + return candidate.copy( + inferredTypes = inference.inferredTypes, + inferredSemanticTypes = inference.inferredSemanticTypes + ) + } + fun handleUpdateConnectionOrder( currentState: FlowEditorState, connection: Connection, diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/viewmodel/FlowEditorState.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/viewmodel/FlowEditorState.kt index 0d04a65c..98bd2a6f 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/viewmodel/FlowEditorState.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/viewmodel/FlowEditorState.kt @@ -19,7 +19,8 @@ data class PendingConnection( val targetNodeId: Long, val targetPortId: String, val sourceType: DataType, - val targetType: DataType + val targetType: DataType, + val originalConnection: org.wip.plugintoolkit.features.flows.model.Connection? = null ) enum class ReadOnlyReason { diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/viewmodel/FlowEditorViewModel.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/viewmodel/FlowEditorViewModel.kt index 2ef1ee33..b3acb928 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/viewmodel/FlowEditorViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/viewmodel/FlowEditorViewModel.kt @@ -227,6 +227,7 @@ class FlowEditorViewModel( is FlowEvent.ConnectPorts, is FlowEvent.AutoConvertAndConnect, is FlowEvent.DeleteConnection, + is FlowEvent.RewireConnection, is FlowEvent.ResetBoard, is FlowEvent.Save, is FlowEvent.SaveAs, @@ -493,7 +494,8 @@ class FlowEditorViewModel( event.sourceNodeId, event.sourcePortId, event.targetNodeId, - event.targetPortId + event.targetPortId, + event.originalConnection ) } @@ -503,6 +505,20 @@ class FlowEditorViewModel( newState = connectionManager.handleDeleteConnection(currentState, event.connection) } + is FlowEvent.RewireConnection -> { + shouldSaveHistory = true + shouldRunTypeInference = true + newState = connectionManager.handleRewireConnection( + currentState, + event.original, + event.sourceNodeId, + event.sourcePortId, + event.targetNodeId, + event.targetPortId, + event.isShiftPressed + ) + } + is FlowEvent.Pan -> handlePan(event.delta) is FlowEvent.Zoom -> handleZoom(event.delta, event.focusPosition, event.isShiftPressed) is FlowEvent.SetZoom -> handleSetZoom(event.scale) diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/viewmodel/FlowViewModel.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/viewmodel/FlowViewModel.kt index 66f84fb7..04519011 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/viewmodel/FlowViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/viewmodel/FlowViewModel.kt @@ -91,10 +91,19 @@ sealed interface FlowEvent { val sourceNodeId: Long, val sourcePortId: String, val targetNodeId: Long, - val targetPortId: String + val targetPortId: String, + val originalConnection: Connection? = null ) : FlowEvent data class DeleteConnection(val connection: Connection) : FlowEvent + data class RewireConnection( + val original: Connection, + val sourceNodeId: Long, + val sourcePortId: String, + val targetNodeId: Long, + val targetPortId: String, + val isShiftPressed: Boolean + ) : FlowEvent data class Pan(val delta: Offset) : FlowEvent data class Zoom(val delta: Float, val focusPosition: Offset, val isShiftPressed: Boolean = false) : FlowEvent diff --git a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/FlowConnectionRewireTest.kt b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/FlowConnectionRewireTest.kt new file mode 100644 index 00000000..516e017b --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/FlowConnectionRewireTest.kt @@ -0,0 +1,279 @@ +package org.wip.plugintoolkit.features.flows + +import androidx.compose.ui.geometry.Offset +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import org.wip.plugintoolkit.api.DataType +import org.wip.plugintoolkit.api.PrimitiveType +import org.wip.plugintoolkit.features.flows.model.Connection +import org.wip.plugintoolkit.features.flows.model.Flow +import org.wip.plugintoolkit.features.flows.model.InputPort +import org.wip.plugintoolkit.features.flows.model.Node +import org.wip.plugintoolkit.features.flows.model.OutputPort +import org.wip.plugintoolkit.features.flows.viewmodel.FlowConnectionManager +import org.wip.plugintoolkit.features.flows.viewmodel.FlowEditorState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class FlowConnectionRewireTest { + private val stringType = DataType.Primitive(PrimitiveType.STRING) + private val intType = DataType.Primitive(PrimitiveType.INT) + private val anyType = DataType.Primitive(PrimitiveType.ANY) + private val incompatibleType = DataType.Object("example.Payload") + + private fun node(id: Long, outputType: DataType? = null, inputType: DataType? = null) = Node.SystemNode( + id = id, + position = Offset.Zero, + title = "Node $id", + systemAction = "test", + inputs = inputType?.let { listOf(InputPort("in", "Input", it)) } ?: emptyList(), + outputs = outputType?.let { listOf(OutputPort("out", "Output", it)) } ?: emptyList() + ) + + private val manager = FlowConnectionManager(null, CoroutineScope(Dispatchers.Unconfined)) {} + + @Test + fun `rewire replaces a connection atomically when compatible`() { + val original = Connection(1, "out", 3, "in") + val state = FlowEditorState( + flow = Flow( + "Flow", + nodes = listOf(node(1, outputType = stringType), node(2, outputType = stringType), node(3, inputType = stringType)), + connections = listOf(original) + ) + ) + + val result = manager.handleRewireConnection(state, original, 2, "out", 3, "in", false) + + assertEquals(1, result.flow.connections.size) + assertEquals(2, result.flow.connections.single().sourceNodeId) + assertTrue(result.hasUnsavedChanges) + } + + @Test + fun `invalid rewire preserves the original connection`() { + val original = Connection(1, "out", 3, "in") + val state = FlowEditorState( + flow = Flow( + "Flow", + nodes = listOf(node(1, outputType = stringType), node(2, outputType = incompatibleType), node(3, inputType = stringType)), + connections = listOf(original) + ) + ) + + val result = manager.handleRewireConnection(state, original, 2, "out", 3, "in", false) + + assertEquals(state, result) + assertEquals(original, result.flow.connections.single()) + } + + @Test + fun `rewire recalculates wildcard inference without the original edge`() { + val original = Connection(1, "out", 3, "in") + val state = FlowEditorState( + flow = Flow( + "Flow", + nodes = listOf( + node(1, outputType = stringType), + node(2, outputType = incompatibleType), + node(3, inputType = anyType) + ), + connections = listOf(original) + ), + inferredTypes = mapOf((3L to "in") to stringType) + ) + + val result = manager.handleRewireConnection(state, original, 2, "out", 3, "in", false) + + assertEquals(listOf(Connection(2, "out", 3, "in")), result.flow.connections) + } + + @Test + fun `rewire can replace the target endpoint`() { + val original = Connection(1, "out", 2, "in") + val state = FlowEditorState( + flow = Flow( + "Flow", + nodes = listOf( + node(1, outputType = stringType), + node(2, inputType = stringType), + node(3, inputType = stringType) + ), + connections = listOf(original) + ) + ) + + val result = manager.handleRewireConnection(state, original, 1, "out", 3, "in", false) + + assertEquals(Connection(1, "out", 3, "in"), result.flow.connections.single()) + } + + @Test + fun `rewire onto occupied scalar input replaces its previous edge`() { + val original = Connection(1, "out", 3, "in") + val occupied = Connection(2, "out", 4, "in") + val state = FlowEditorState( + flow = Flow( + "Flow", + nodes = listOf( + node(1, outputType = stringType), + node(2, outputType = stringType), + node(3, inputType = stringType), + node(4, inputType = stringType) + ), + connections = listOf(original, occupied) + ) + ) + + val result = manager.handleRewireConnection(state, original, 1, "out", 4, "in", false) + + assertEquals(listOf(Connection(1, "out", 4, "in")), result.flow.connections) + } + + @Test + fun `rewire preserves array order and keeps indices contiguous`() { + val arrayType = DataType.Array(stringType) + val original = Connection(1, "out", 4, "in", orderIndex = 0) + val state = FlowEditorState( + flow = Flow( + "Flow", + nodes = listOf( + node(1, outputType = arrayType), + node(2, outputType = arrayType), + node(3, outputType = arrayType), + node(4, inputType = arrayType), + node(5, outputType = arrayType) + ), + connections = listOf( + original, + Connection(2, "out", 4, "in", orderIndex = 1), + Connection(3, "out", 4, "in", orderIndex = 2) + ) + ) + ) + + val result = manager.handleRewireConnection(state, original, 5, "out", 4, "in", false) + val orderedSources = result.flow.connections.sortedBy { it.orderIndex }.map { it.sourceNodeId } + + assertEquals(listOf(5L, 2L, 3L), orderedSources) + assertEquals(listOf(0, 1, 2), result.flow.connections.sortedBy { it.orderIndex }.map { it.orderIndex }) + } + + @Test + fun `rewire to duplicate array edge preserves the original`() { + val arrayType = DataType.Array(stringType) + val original = Connection(1, "out", 3, "in", orderIndex = 0) + val duplicate = Connection(2, "out", 3, "in", orderIndex = 1) + val state = FlowEditorState( + flow = Flow( + "Flow", + nodes = listOf( + node(1, outputType = arrayType), + node(2, outputType = arrayType), + node(3, inputType = arrayType) + ), + connections = listOf(original, duplicate) + ) + ) + + val result = manager.handleRewireConnection(state, original, 2, "out", 3, "in", false) + + assertEquals(state, result) + } + + @Test + fun `convertible rewire remains atomic until conversion is confirmed`() { + val original = Connection(1, "out", 3, "in") + val state = FlowEditorState( + flow = Flow( + "Flow", + nodes = listOf( + node(1, outputType = intType), + node(2, outputType = stringType), + node(3, inputType = intType) + ), + connections = listOf(original) + ), + nextId = 10 + ) + + val pending = manager.handleRewireConnection(state, original, 2, "out", 3, "in", false) + assertEquals(listOf(original), pending.flow.connections) + assertEquals(original, assertNotNull(pending.pendingConnection).originalConnection) + + val converted = manager.handleAutoConvertAndConnect( + pending.copy(pendingConnection = null), + 2, + "out", + 3, + "in", + original + ) + assertTrue(original !in converted.flow.connections) + assertTrue(converted.flow.nodes.any { it.id == 10L && it is Node.SystemNode && it.systemAction == "convert" }) + assertEquals(2, converted.flow.connections.size) + } + + @Test + fun `direct connect rejects a convertible pair without a converter`() { + val state = FlowEditorState( + flow = Flow( + "Flow", + nodes = listOf(node(1, outputType = stringType), node(2, inputType = intType)) + ) + ) + + assertEquals(state, manager.handleConnectPorts(state, 1, "out", 2, "in")) + } + + @Test + fun `direct connect uses inferred array target when preserving existing edges`() { + val arrayType = DataType.Array(stringType) + val existing = Connection(1, "out", 3, "in", orderIndex = 0) + val state = FlowEditorState( + flow = Flow( + "Flow", + nodes = listOf( + node(1, outputType = arrayType), + node(2, outputType = arrayType), + node(3, inputType = anyType) + ), + connections = listOf(existing) + ), + inferredTypes = mapOf((3L to "in") to arrayType) + ) + + val result = manager.handleConnectPorts(state, 2, "out", 3, "in") + + assertEquals(2, result.flow.connections.size) + assertEquals(setOf(1L, 2L), result.flow.connections.map { it.sourceNodeId }.toSet()) + assertEquals(listOf(0, 1), result.flow.connections.sortedBy { it.orderIndex }.map { it.orderIndex }) + } + + @Test + fun `cyclic rewire preserves the original`() { + val original = Connection(4, "out", 1, "in") + val state = FlowEditorState( + flow = Flow( + "Flow", + nodes = listOf( + node(1, outputType = stringType, inputType = stringType), + node(2, outputType = stringType, inputType = stringType), + node(3, outputType = stringType, inputType = stringType), + node(4, outputType = stringType) + ), + connections = listOf( + original, + Connection(1, "out", 2, "in"), + Connection(2, "out", 3, "in") + ) + ) + ) + + val result = manager.handleRewireConnection(state, original, 3, "out", 1, "in", false) + + assertEquals(state, result) + } +}