package me.eternal.purrfect.common.scripting.ui import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.foundation.interaction.MutableInteractionSource import kotlinx.coroutines.launch import me.eternal.purrfect.common.logger.AbstractLogger import me.eternal.purrfect.common.scripting.ui.components.Node import me.eternal.purrfect.common.scripting.ui.components.NodeType import me.eternal.purrfect.common.scripting.ui.components.impl.ActionNode import me.eternal.purrfect.common.scripting.ui.components.impl.ActionType import kotlin.math.abs @Composable @Suppress("UNCHECKED_CAST") private fun DrawNode(node: Node) { val coroutineScope = rememberCoroutineScope() val cachedAttributes = remember { mutableStateMapOf(*node.attributes.toList().toTypedArray()) } node.uiChangeDetection = { key, value -> coroutineScope.launch { cachedAttributes[key] = value } } DisposableEffect(Unit) { onDispose { node.uiChangeDetection = { _, _ -> } } } val arrangement = cachedAttributes["arrangement"] val alignment = cachedAttributes["alignment"] val spacing = cachedAttributes["spacing"]?.toString()?.toInt()?.let { abs(it) } val rowColumnModifier = Modifier .then(if (cachedAttributes["fillMaxWidth"] as? Boolean == true) Modifier.fillMaxWidth() else Modifier) .then(if (cachedAttributes["fillMaxHeight"] as? Boolean == true) Modifier.fillMaxHeight() else Modifier) .padding( (cachedAttributes["padding"] ?.toString() ?.toInt() ?.let { abs(it) } ?: 2).dp) fun runCallbackSafe(callback: () -> Unit) { runCatching { callback() }.onFailure { AbstractLogger.directError("Error running callback", it) } } @Composable fun NodeLabel() { Text( text = cachedAttributes["label"] as String, fontSize = (cachedAttributes["fontSize"]?.toString()?.toInt() ?: 14).sp, color = (cachedAttributes["color"] as? Long)?.let { Color(it) } ?: Color.White ) } if (cachedAttributes["visibility"] != "gone") { AnimatedVisibility( visible = cachedAttributes["visibility"] != "invisible", ) { when (node.type) { NodeType.ACTION -> { when ((node as ActionNode).actionType) { ActionType.LAUNCHED -> { LaunchedEffect(node.key) { runCallbackSafe { node.callback() } } } ActionType.DISPOSE -> { DisposableEffect(Unit) { onDispose { runCallbackSafe { node.callback() } } } } } } NodeType.COLUMN -> { Column( verticalArrangement = arrangement as? Arrangement.Vertical ?: spacing?.let { Arrangement.spacedBy(it.dp) } ?: Arrangement.Top, horizontalAlignment = alignment as? Alignment.Horizontal ?: Alignment.Start, modifier = rowColumnModifier ) { node.children.forEach { child -> DrawNode(child) } } } NodeType.ROW -> { Row( horizontalArrangement = arrangement as? Arrangement.Horizontal ?: spacing?.let { Arrangement.spacedBy(it.dp) } ?: Arrangement.SpaceBetween, verticalAlignment = alignment as? Alignment.Vertical ?: Alignment.CenterVertically, modifier = rowColumnModifier ) { node.children.forEach { child -> DrawNode(child) } } } NodeType.TEXT -> NodeLabel() NodeType.SWITCH -> { var switchState by remember { mutableStateOf(cachedAttributes["state"] as Boolean) } Switch( checked = switchState, onCheckedChange = { state -> runCallbackSafe { switchState = state node.setAttribute("state", state) (cachedAttributes["callback"] as? (Boolean) -> Unit)?.let { it(state) } } }, colors = androidx.compose.material3.SwitchDefaults.colors( checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF8C7BFF).copy(alpha = 0.66f), uncheckedThumbColor = Color.White.copy(alpha = 0.9f), uncheckedTrackColor = Color.White.copy(alpha = 0.20f), checkedBorderColor = Color.Transparent, uncheckedBorderColor = Color.Transparent ) ) } NodeType.SLIDER -> { var sliderValue by remember { mutableFloatStateOf((cachedAttributes["value"] as Int).toFloat()) } Slider( value = sliderValue, onValueChange = { value -> runCallbackSafe { sliderValue = value node.setAttribute("value", value.toInt()) (cachedAttributes["callback"] as? (Int) -> Unit)?.let { it(value.toInt()) } } }, valueRange = (cachedAttributes["min"] as Int).toFloat()..(cachedAttributes["max"] as Int).toFloat(), steps = cachedAttributes["step"] as Int, colors = SliderDefaults.colors( thumbColor = Color(0xFF8C7BFF), activeTrackColor = Color(0xFF8C7BFF).copy(alpha = 0.72f), inactiveTrackColor = Color.White.copy(alpha = 0.20f), ) ) } NodeType.BUTTON -> { val buttonShape = RoundedCornerShape(999.dp) Box( modifier = Modifier .clip(buttonShape) .background(Color.White.copy(alpha = 0.08f), buttonShape) .border(1.dp, Color.White.copy(alpha = 0.20f), buttonShape) .clickable( indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = { runCallbackSafe { (cachedAttributes["callback"] as? () -> Unit)?.let { it() } } } ) .padding(horizontal = 16.dp, vertical = 9.dp), contentAlignment = Alignment.Center ) { Text( text = cachedAttributes["label"] as String, color = Color.White, fontSize = (cachedAttributes["fontSize"]?.toString()?.toInt() ?: 14).sp, fontWeight = FontWeight.SemiBold ) } } NodeType.TEXT_INPUT -> { var textInputValue by remember { mutableStateOf(cachedAttributes["value"].toString()) } val inputShape = RoundedCornerShape(14.dp) BasicTextField( value = textInputValue, readOnly = cachedAttributes["readonly"] as? Boolean ?: false, singleLine = cachedAttributes["singleLine"] as? Boolean ?: true, maxLines = cachedAttributes["maxLines"] as? Int ?: 1, textStyle = TextStyle( color = Color.White, fontSize = 14.sp ), cursorBrush = SolidColor(Color(0xFF8C7BFF)), onValueChange = { value -> runCallbackSafe { textInputValue = value node.setAttribute("value", value) (cachedAttributes["callback"] as? (String) -> Unit)?.let { it(value) } } }, decorationBox = { innerTextField -> Box( modifier = Modifier .fillMaxWidth() .background(Color.White.copy(alpha = 0.08f), inputShape) .border(1.dp, Color.White.copy(alpha = 0.18f), inputShape) .padding(horizontal = 12.dp, vertical = 10.dp) ) { if (textInputValue.isEmpty()) { Text( text = cachedAttributes["placeholder"].toString(), color = Color.White.copy(alpha = 0.6f), fontSize = 14.sp ) } innerTextField() } } ) } else -> {} } } } } @Composable fun ScriptInterface(interfaceBuilder: InterfaceBuilder) { Column( modifier = Modifier .fillMaxWidth() .padding(8.dp) ) { interfaceBuilder.nodes.forEach { node -> DrawNode(node) } DisposableEffect(Unit) { onDispose { runCatching { interfaceBuilder.onDisposeCallback?.invoke() }.onFailure { AbstractLogger.directError("Error running onDisposed callback", it) } } } } }