diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/CodingAgentRenderer.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/CodingAgentRenderer.kt index a0814a58ac..1281248b9a 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/CodingAgentRenderer.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/CodingAgentRenderer.kt @@ -50,6 +50,22 @@ interface CodingAgentRenderer { fun updateTokenInfo(tokenInfo: TokenInfo) {} + /** + * Handle task-boundary tool call to update task progress display. + * Called when the agent uses the task-boundary tool to mark task status. + * + * This is an optional method primarily used by UI renderers that display + * task progress visually. Console and server renderers typically don't need + * to implement this. + * + * @param taskName The name of the task + * @param status The task status (e.g., "WORKING", "DONE", "FAILED") + * @param summary Optional summary of the task progress + */ + fun handleTaskBoundary(taskName: String, status: String, summary: String = "") { + // Default: no-op for renderers that don't display task progress + } + /** * Render a compact plan summary bar. * Called when plan is created or updated to show progress in a compact format. diff --git a/mpp-idea/build.gradle.kts b/mpp-idea/build.gradle.kts index a4cd73a467..9c1aff27fa 100644 --- a/mpp-idea/build.gradle.kts +++ b/mpp-idea/build.gradle.kts @@ -344,6 +344,8 @@ project(":") { exclude(group = "cc.unitmesh.viewer.web", module = "mpp-viewer-web") exclude(group = "cc.unitmesh", module = "mpp-viewer-web") } + + testImplementation(kotlin("test")) } tasks { diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt index 396272e088..8dba239e26 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt @@ -6,6 +6,8 @@ import cc.unitmesh.agent.render.BaseRenderer import cc.unitmesh.devins.llm.MessageRole import cc.unitmesh.agent.render.RendererUtils +import cc.unitmesh.agent.render.TaskInfo +import cc.unitmesh.agent.render.TaskStatus import cc.unitmesh.agent.render.TimelineItem import cc.unitmesh.agent.render.ToolCallDisplayInfo import cc.unitmesh.agent.render.ToolCallInfo @@ -82,6 +84,10 @@ class JewelRenderer : BaseRenderer() { private val _currentPlan = MutableStateFlow(null) val currentPlan: StateFlow = _currentPlan.asStateFlow() + // Task tracking (from task-boundary tool) + private val _tasks = MutableStateFlow>(emptyList()) + val tasks: StateFlow> = _tasks.asStateFlow() + /** * Set the current plan directly. * Used to sync with PlanStateService from CodingAgent. @@ -152,6 +158,11 @@ class JewelRenderer : BaseRenderer() { jewelRendererLogger.info("renderToolCall: parsed params keys=${params.keys}") + // Handle task-boundary tool - update task list + if (toolName == "task-boundary") { + updateTaskFromToolCall(params) + } + // Handle plan management tool - update plan state if (toolName == "plan") { jewelRendererLogger.info("renderToolCall: detected plan tool, calling updatePlanFromToolCall") @@ -178,6 +189,11 @@ class JewelRenderer : BaseRenderer() { // Convert Map to Map for internal use val stringParams = params.mapValues { it.value.toString() } + // Handle task-boundary tool - update task list + if (toolName == "task-boundary") { + updateTaskFromToolCall(stringParams) + } + // Handle plan management tool - update plan state with original params if (toolName == "plan") { updatePlanFromToolCallWithAnyParams(params) @@ -322,6 +338,49 @@ class JewelRenderer : BaseRenderer() { } } + /** + * Update task list from task-boundary tool call + */ + private fun updateTaskFromToolCall(params: Map) { + val taskName = params["taskName"] ?: return + val statusStr = params["status"] ?: "WORKING" + val summary = params["summary"] ?: "" + val status = TaskStatus.fromString(statusStr) + + _tasks.update { currentTasks -> + val existingIndex = currentTasks.indexOfFirst { it.taskName == taskName } + if (existingIndex >= 0) { + // Update existing task + currentTasks.toMutableList().apply { + this[existingIndex] = currentTasks[existingIndex].copy( + status = status, + summary = summary, + timestamp = System.currentTimeMillis() + ) + } + } else { + // Add new task + currentTasks + TaskInfo( + taskName = taskName, + status = status, + summary = summary + ) + } + } + } + + /** + * Handle task-boundary tool call to update task progress display. + * Overrides the interface method to provide UI-specific task tracking. + */ + override fun handleTaskBoundary(taskName: String, status: String, summary: String) { + updateTaskFromToolCall(mapOf( + "taskName" to taskName, + "status" to status, + "summary" to summary + )) + } + override fun renderToolResult( toolName: String, success: Boolean, diff --git a/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/renderer/JewelRendererTest.kt b/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/renderer/JewelRendererTest.kt index a5f6afdb76..f21dfa6db2 100644 --- a/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/renderer/JewelRendererTest.kt +++ b/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/renderer/JewelRendererTest.kt @@ -1,7 +1,6 @@ package cc.unitmesh.devins.idea.renderer import cc.unitmesh.devins.llm.MessageRole -import cc.unitmesh.agent.render.TaskStatus import cc.unitmesh.agent.render.TimelineItem import cc.unitmesh.llm.compression.TokenInfo import kotlinx.coroutines.flow.first diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/ComposeRenderer.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/ComposeRenderer.kt index 5a43d25947..931e4e1c5f 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/ComposeRenderer.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/ComposeRenderer.kt @@ -280,6 +280,18 @@ class ComposeRenderer : BaseRenderer() { } } + /** + * Handle task-boundary tool call to update task progress display. + * Overrides the interface method to provide UI-specific task tracking. + */ + override fun handleTaskBoundary(taskName: String, status: String, summary: String) { + updateTaskFromToolCall(mapOf( + "taskName" to taskName, + "status" to status, + "summary" to summary + )) + } + /** * Update plan state from plan management tool call (string params version) */ diff --git a/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/nano/ComposeActionHandler.kt b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/nano/ComposeActionHandler.kt new file mode 100644 index 0000000000..172b8db680 --- /dev/null +++ b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/nano/ComposeActionHandler.kt @@ -0,0 +1,200 @@ +package cc.unitmesh.devins.ui.nano + +import cc.unitmesh.xuiper.action.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.awt.Desktop +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import javax.swing.JOptionPane + +/** + * Compose/Desktop implementation of NanoActionHandler + * + * Handles NanoUI actions in a Compose Desktop environment. + * Provides platform-specific implementations for navigation, toast, and fetch. + * + * Example: + * ```kotlin + * val handler = ComposeActionHandler( + * scope = rememberCoroutineScope(), + * onNavigate = { route -> navController.navigate(route) }, + * onToast = { message -> snackbarHostState.showSnackbar(message) } + * ) + * + * handler.registerCustomAction("AddTask") { payload, context -> + * val title = payload["title"] as? String ?: "" + * taskRepository.add(Task(title)) + * ActionResult.Success + * } + * ``` + */ +class ComposeActionHandler( + private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO), + private val onNavigate: ((String) -> Unit)? = null, + private val onToast: ((String) -> Unit)? = null, + private val onFetchComplete: ((String, Boolean, String?) -> Unit)? = null +) : BaseNanoActionHandler() { + + private val httpClient = HttpClient.newBuilder().build() + + override fun handleNavigate( + navigate: NanoAction.Navigate, + context: NanoActionContext + ): ActionResult { + return try { + if (onNavigate != null) { + onNavigate.invoke(navigate.to) + } else { + // Default: open in browser if it's a URL + if (navigate.to.startsWith("http://") || navigate.to.startsWith("https://")) { + Desktop.getDesktop().browse(URI(navigate.to)) + } + } + ActionResult.Success + } catch (e: Exception) { + ActionResult.Error("Navigation failed: ${e.message}", e) + } + } + + override fun handleFetch( + fetch: NanoAction.Fetch, + context: NanoActionContext + ): ActionResult { + // Set loading state if specified + fetch.loadingState?.let { path -> + context.set(path, true) + } + + scope.launch { + try { + val requestBuilder = HttpRequest.newBuilder() + .uri(URI(fetch.url)) + + // Set method + when (fetch.method) { + HttpMethod.GET -> requestBuilder.GET() + HttpMethod.POST -> { + val body = buildRequestBody(fetch.body, context) + requestBuilder.POST(HttpRequest.BodyPublishers.ofString(body)) + requestBuilder.header("Content-Type", fetch.contentType.mimeType) + } + HttpMethod.PUT -> { + val body = buildRequestBody(fetch.body, context) + requestBuilder.PUT(HttpRequest.BodyPublishers.ofString(body)) + requestBuilder.header("Content-Type", fetch.contentType.mimeType) + } + HttpMethod.DELETE -> requestBuilder.DELETE() + else -> requestBuilder.GET() + } + + // Add headers + fetch.headers?.forEach { (key, value) -> + requestBuilder.header(key, value) + } + + val response = httpClient.send( + requestBuilder.build(), + HttpResponse.BodyHandlers.ofString() + ) + + // Update loading state + fetch.loadingState?.let { path -> + context.set(path, false) + } + + if (response.statusCode() in 200..299) { + // Success + fetch.responseBinding?.let { path -> + context.set(path, response.body()) + } + + fetch.onSuccess?.let { successAction -> + handleAction(successAction, context) + } + + onFetchComplete?.invoke(fetch.url, true, response.body()) + } else { + // Error + val errorMsg = "HTTP ${response.statusCode()}: ${response.body()}" + fetch.errorBinding?.let { path -> + context.set(path, errorMsg) + } + + fetch.onError?.let { errorAction -> + handleAction(errorAction, context) + } + + onFetchComplete?.invoke(fetch.url, false, errorMsg) + } + + } catch (e: Exception) { + fetch.loadingState?.let { path -> + context.set(path, false) + } + fetch.errorBinding?.let { path -> + context.set(path, e.message) + } + fetch.onError?.let { errorAction -> + handleAction(errorAction, context) + } + onFetchComplete?.invoke(fetch.url, false, e.message) + } + } + + return ActionResult.Pending { /* async operation */ } + } + + override fun handleShowToast( + toast: NanoAction.ShowToast, + context: NanoActionContext + ): ActionResult { + return try { + if (onToast != null) { + onToast.invoke(toast.message) + } else { + // Default: use Swing dialog (for desktop) + javax.swing.SwingUtilities.invokeLater { + JOptionPane.showMessageDialog(null, toast.message) + } + } + ActionResult.Success + } catch (e: Exception) { + ActionResult.Error("Toast failed: ${e.message}", e) + } + } + + private fun buildRequestBody( + body: Map?, + context: NanoActionContext + ): String { + if (body == null) return "" + + val resolvedBody = body.mapValues { (_, field) -> + when (field) { + is BodyField.Literal -> field.value + is BodyField.StateBinding -> context.get(field.path)?.toString() ?: "" + } + } + + // Simple JSON serialization with proper escaping + return buildString { + append("{") + resolvedBody.entries.forEachIndexed { index, (key, value) -> + if (index > 0) append(",") + append("\"$key\":") + when (value) { + is String -> append("\"${value.replace("\\", "\\\\").replace("\"", "\\\"")}\"") + is Number, is Boolean -> append(value) + null -> append("null") + else -> append("\"${value.toString().replace("\\", "\\\\").replace("\"", "\\\"")}\"") + } + } + append("}") + } + } +} + diff --git a/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/nano/NanoDSLDemo.kt b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/nano/NanoDSLDemo.kt index eb9c6589c9..6d120cc209 100644 --- a/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/nano/NanoDSLDemo.kt +++ b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/nano/NanoDSLDemo.kt @@ -256,7 +256,7 @@ fun NanoDSLDemo( .verticalScroll(rememberScrollState()) ) { parsedIR?.let { ir -> - ComposeNanoRenderer.Render(ir) + StatefulNanoRenderer.Render(ir) } ?: run { Box( modifier = Modifier.fillMaxSize(), @@ -305,13 +305,18 @@ component ShoppingItem: private val COUNTER_DSL = """ component Counter: + state: + count: int = 0 + Card(padding="lg", shadow="md"): VStack(spacing="md"): Text("Counter Example", style="h2") HStack(spacing="md", align="center", justify="center"): - Button("-", intent="secondary") - Text("0", style="h1") - Button("+", intent="primary") + Button("-", intent="secondary"): + on_click: state.count -= 1 + Text(content << state.count, style="h1") + Button("+", intent="primary"): + on_click: state.count += 1 Divider Text("Click buttons to change value", style="caption") """.trimIndent() diff --git a/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/nano/StatefulNanoRenderer.kt b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/nano/StatefulNanoRenderer.kt new file mode 100644 index 0000000000..24d488243b --- /dev/null +++ b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/nano/StatefulNanoRenderer.kt @@ -0,0 +1,493 @@ +package cc.unitmesh.devins.ui.nano + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.unitmesh.xuiper.ir.NanoActionIR +import cc.unitmesh.xuiper.ir.NanoIR +import cc.unitmesh.xuiper.ir.NanoStateIR +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonPrimitive + +/** + * Stateful NanoUI Compose Renderer + * + * This renderer maintains state and handles actions for interactive NanoDSL components. + * It wraps the component rendering with a state context that: + * 1. Initializes state from NanoIR state definitions + * 2. Passes state values to components via bindings + * 3. Updates state when actions are triggered + */ +object StatefulNanoRenderer { + + /** + * Render a NanoIR tree with state management. + * Automatically initializes state from the IR and provides action handlers. + */ + @Composable + fun Render(ir: NanoIR, modifier: Modifier = Modifier) { + // Initialize state from IR + val stateMap = remember { mutableStateMapOf() } + + // Initialize state values from IR state definitions + LaunchedEffect(ir) { + ir.state?.variables?.forEach { (name, varDef) -> + val defaultValue = varDef.defaultValue + stateMap[name] = when (varDef.type) { + "int" -> defaultValue?.jsonPrimitive?.intOrNull ?: 0 + "float" -> defaultValue?.jsonPrimitive?.content?.toFloatOrNull() ?: 0f + "bool" -> defaultValue?.jsonPrimitive?.booleanOrNull ?: false + "str" -> defaultValue?.jsonPrimitive?.content ?: "" + else -> defaultValue?.jsonPrimitive?.content ?: "" + } + } + } + + // Create action handler + val handleAction: (NanoActionIR) -> Unit = handleAction@{ action -> + when (action.type) { + "stateMutation" -> { + val payload = action.payload ?: return@handleAction + val path = payload["path"]?.jsonPrimitive?.content ?: return@handleAction + val operation = payload["operation"]?.jsonPrimitive?.content ?: "SET" + val valueStr = payload["value"]?.jsonPrimitive?.content ?: "" + + val currentValue = stateMap[path] + val newValue = when (operation) { + "ADD" -> { + when (currentValue) { + is Int -> currentValue + (valueStr.toIntOrNull() ?: 1) + is Float -> currentValue + (valueStr.toFloatOrNull() ?: 1f) + else -> currentValue + } + } + "SUBTRACT" -> { + when (currentValue) { + is Int -> currentValue - (valueStr.toIntOrNull() ?: 1) + is Float -> currentValue - (valueStr.toFloatOrNull() ?: 1f) + else -> currentValue + } + } + "SET" -> { + when (currentValue) { + is Int -> valueStr.toIntOrNull() ?: 0 + is Float -> valueStr.toFloatOrNull() ?: 0f + is Boolean -> valueStr.toBooleanStrictOrNull() ?: false + else -> valueStr + } + } + else -> valueStr + } + + if (newValue != null) { + stateMap[path] = newValue + } + } + } + } + + RenderNode(ir, stateMap, handleAction, modifier) + } + + @Composable + private fun RenderNode( + ir: NanoIR, + state: Map, + onAction: (NanoActionIR) -> Unit, + modifier: Modifier = Modifier + ) { + when (ir.type) { + "VStack" -> RenderVStack(ir, state, onAction, modifier) + "HStack" -> RenderHStack(ir, state, onAction, modifier) + "Card" -> RenderCard(ir, state, onAction, modifier) + "Form" -> RenderForm(ir, state, onAction, modifier) + "Text" -> RenderText(ir, state, modifier) + "Image" -> RenderImage(ir, modifier) + "Badge" -> RenderBadge(ir, modifier) + "Divider" -> RenderDivider(modifier) + "Button" -> RenderButton(ir, onAction, modifier) + "Input" -> RenderInput(ir, state, onAction, modifier) + "Checkbox" -> RenderCheckbox(ir, state, onAction, modifier) + "TextArea" -> RenderTextArea(ir, state, onAction, modifier) + "Select" -> RenderSelect(ir, state, onAction, modifier) + "Component" -> RenderComponent(ir, state, onAction, modifier) + else -> RenderUnknown(ir, modifier) + } + } + + // Layout Components + + @Composable + private fun RenderVStack( + ir: NanoIR, state: Map, onAction: (NanoActionIR) -> Unit, modifier: Modifier + ) { + val spacing = ir.props["spacing"]?.jsonPrimitive?.content?.toSpacing() ?: 8.dp + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(spacing)) { + ir.children?.forEach { child -> RenderNode(child, state, onAction) } + } + } + + @Composable + private fun RenderHStack( + ir: NanoIR, state: Map, onAction: (NanoActionIR) -> Unit, modifier: Modifier + ) { + val spacing = ir.props["spacing"]?.jsonPrimitive?.content?.toSpacing() ?: 8.dp + val align = ir.props["align"]?.jsonPrimitive?.content + val justify = ir.props["justify"]?.jsonPrimitive?.content + + val verticalAlignment = when (align) { + "center" -> Alignment.CenterVertically + "top" -> Alignment.Top + "bottom" -> Alignment.Bottom + else -> Alignment.CenterVertically + } + val horizontalArrangement = when (justify) { + "center" -> Arrangement.Center + "between" -> Arrangement.SpaceBetween + "end" -> Arrangement.End + else -> Arrangement.spacedBy(spacing) + } + + Row( + modifier = modifier, + horizontalArrangement = horizontalArrangement, + verticalAlignment = verticalAlignment + ) { + ir.children?.forEach { child -> RenderNode(child, state, onAction) } + } + } + + @Composable + private fun RenderCard( + ir: NanoIR, state: Map, onAction: (NanoActionIR) -> Unit, modifier: Modifier + ) { + val padding = ir.props["padding"]?.jsonPrimitive?.content?.toPadding() ?: 16.dp + val shadow = ir.props["shadow"]?.jsonPrimitive?.content + + val elevation = when (shadow) { + "sm" -> CardDefaults.cardElevation(defaultElevation = 2.dp) + "md" -> CardDefaults.cardElevation(defaultElevation = 4.dp) + "lg" -> CardDefaults.cardElevation(defaultElevation = 8.dp) + else -> CardDefaults.cardElevation() + } + + Card(modifier = modifier.fillMaxWidth(), elevation = elevation) { + Column(modifier = Modifier.padding(padding)) { + ir.children?.forEach { child -> RenderNode(child, state, onAction) } + } + } + } + + @Composable + private fun RenderForm( + ir: NanoIR, state: Map, onAction: (NanoActionIR) -> Unit, modifier: Modifier + ) { + Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) { + ir.children?.forEach { child -> RenderNode(child, state, onAction) } + } + } + + @Composable + private fun RenderComponent( + ir: NanoIR, state: Map, onAction: (NanoActionIR) -> Unit, modifier: Modifier + ) { + Column(modifier = modifier) { + ir.children?.forEach { child -> RenderNode(child, state, onAction) } + } + } + + // Content Components + + @Composable + private fun RenderText(ir: NanoIR, state: Map, modifier: Modifier) { + // Check for binding first + val binding = ir.bindings?.get("content") + val content = if (binding != null) { + // Get value from state based on binding expression + val expr = binding.expression.removePrefix("state.") + state[expr]?.toString() ?: "" + } else { + ir.props["content"]?.jsonPrimitive?.content ?: "" + } + + val style = ir.props["style"]?.jsonPrimitive?.content + + val textStyle = when (style) { + "h1" -> MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.Bold) + "h2" -> MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold) + "h3" -> MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Medium) + "h4" -> MaterialTheme.typography.titleLarge + "body" -> MaterialTheme.typography.bodyLarge + "caption" -> MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + else -> MaterialTheme.typography.bodyMedium + } + + Text(text = content, style = textStyle, modifier = modifier) + } + + @Composable + private fun RenderImage(ir: NanoIR, modifier: Modifier) { + val src = ir.props["src"]?.jsonPrimitive?.content ?: "" + Box( + modifier = modifier + .fillMaxWidth() + .height(200.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Text("Image: $src", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + + @Composable + private fun RenderBadge(ir: NanoIR, modifier: Modifier) { + val text = ir.props["text"]?.jsonPrimitive?.content ?: "" + val colorName = ir.props["color"]?.jsonPrimitive?.content + + val bgColor = when (colorName) { + "green" -> Color(0xFF4CAF50) + "red" -> Color(0xFFF44336) + "blue" -> Color(0xFF2196F3) + "yellow" -> Color(0xFFFFEB3B) + "orange" -> Color(0xFFFF9800) + else -> MaterialTheme.colorScheme.primaryContainer + } + + val textColor = if (colorName == "yellow") Color.Black else Color.White + + Surface(modifier = modifier, shape = RoundedCornerShape(4.dp), color = bgColor) { + Text( + text = text, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + color = textColor, + fontSize = 12.sp + ) + } + } + + @Composable + private fun RenderDivider(modifier: Modifier) { + HorizontalDivider(modifier.padding(vertical = 8.dp)) + } + + // Input Components + + @Composable + private fun RenderButton(ir: NanoIR, onAction: (NanoActionIR) -> Unit, modifier: Modifier) { + val label = ir.props["label"]?.jsonPrimitive?.content ?: "Button" + val intent = ir.props["intent"]?.jsonPrimitive?.content + val onClick = ir.actions?.get("onClick") + + when (intent) { + "secondary" -> OutlinedButton( + onClick = { onClick?.let { onAction(it) } }, + modifier = modifier + ) { + Text(label) + } + else -> { + val colors = when (intent) { + "danger" -> ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error) + else -> ButtonDefaults.buttonColors() + } + Button( + onClick = { onClick?.let { onAction(it) } }, + colors = colors, + modifier = modifier + ) { + Text(label) + } + } + } + } + + @Composable + private fun RenderInput( + ir: NanoIR, state: Map, onAction: (NanoActionIR) -> Unit, modifier: Modifier + ) { + val placeholder = ir.props["placeholder"]?.jsonPrimitive?.content ?: "" + val binding = ir.bindings?.get("value") + val statePath = binding?.expression?.removePrefix("state.") + + var value by remember(statePath, state[statePath]) { + mutableStateOf(state[statePath]?.toString() ?: "") + } + + OutlinedTextField( + value = value, + onValueChange = { newValue -> + value = newValue + if (statePath != null) { + onAction(NanoActionIR( + type = "stateMutation", + payload = mapOf( + "path" to JsonPrimitive(statePath), + "operation" to JsonPrimitive("SET"), + "value" to JsonPrimitive(newValue) + ) + )) + } + }, + placeholder = { Text(placeholder) }, + modifier = modifier.fillMaxWidth(), + singleLine = true + ) + } + + @Composable + private fun RenderCheckbox( + ir: NanoIR, state: Map, onAction: (NanoActionIR) -> Unit, modifier: Modifier + ) { + val binding = ir.bindings?.get("checked") + val statePath = binding?.expression?.removePrefix("state.") + val checked = (state[statePath] as? Boolean) ?: false + + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = checked, + onCheckedChange = { newValue -> + if (statePath != null) { + onAction(NanoActionIR( + type = "stateMutation", + payload = mapOf( + "path" to JsonPrimitive(statePath), + "operation" to JsonPrimitive("SET"), + "value" to JsonPrimitive(newValue.toString()) + ) + )) + } + } + ) + } + } + + @Composable + private fun RenderTextArea( + ir: NanoIR, state: Map, onAction: (NanoActionIR) -> Unit, modifier: Modifier + ) { + val placeholder = ir.props["placeholder"]?.jsonPrimitive?.content ?: "" + val rows = ir.props["rows"]?.jsonPrimitive?.intOrNull ?: 4 + val binding = ir.bindings?.get("value") + val statePath = binding?.expression?.removePrefix("state.") + + var value by remember(statePath, state[statePath]) { + mutableStateOf(state[statePath]?.toString() ?: "") + } + + OutlinedTextField( + value = value, + onValueChange = { newValue -> + value = newValue + if (statePath != null) { + onAction(NanoActionIR( + type = "stateMutation", + payload = mapOf( + "path" to JsonPrimitive(statePath), + "operation" to JsonPrimitive("SET"), + "value" to JsonPrimitive(newValue) + ) + )) + } + }, + placeholder = { Text(placeholder) }, + modifier = modifier.fillMaxWidth().height((rows * 24).dp), + minLines = rows + ) + } + + @Composable + private fun RenderSelect( + ir: NanoIR, state: Map, onAction: (NanoActionIR) -> Unit, modifier: Modifier + ) { + val placeholder = ir.props["placeholder"]?.jsonPrimitive?.content ?: "Select..." + val binding = ir.bindings?.get("value") + val statePath = binding?.expression?.removePrefix("state.") + val selectedValue = state[statePath]?.toString() ?: "" + var expanded by remember { mutableStateOf(false) } + + // Read options from IR props + val options: List = ir.props["options"]?.let { optionsElement -> + try { + (optionsElement as? JsonArray) + ?.mapNotNull { it.jsonPrimitive.contentOrNull } + } catch (e: Exception) { null } + } ?: emptyList() + + Box(modifier = modifier) { + OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) { + Text(if (selectedValue.isNotEmpty()) selectedValue else placeholder) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + options.forEach { option -> + DropdownMenuItem( + text = { Text(option) }, + onClick = { + expanded = false + if (statePath != null) { + onAction(NanoActionIR( + type = "stateMutation", + payload = mapOf( + "path" to JsonPrimitive(statePath), + "operation" to JsonPrimitive("SET"), + "value" to JsonPrimitive(option) + ) + )) + } + } + ) + } + } + } + } + + @Composable + private fun RenderUnknown(ir: NanoIR, modifier: Modifier) { + Surface( + modifier = modifier.border(1.dp, Color.Red, RoundedCornerShape(4.dp)), + color = Color.Red.copy(alpha = 0.1f) + ) { + Text( + text = "Unknown: ${ir.type}", + modifier = Modifier.padding(8.dp), + color = Color.Red + ) + } + } + + // Utility extensions + private fun String.toSpacing() = when (this) { + "xs" -> 4.dp + "sm" -> 8.dp + "md" -> 16.dp + "lg" -> 24.dp + "xl" -> 32.dp + else -> 8.dp + } + + private fun String.toPadding() = when (this) { + "xs" -> 4.dp + "sm" -> 8.dp + "md" -> 16.dp + "lg" -> 24.dp + "xl" -> 32.dp + else -> 16.dp + } +} + diff --git a/mpp-vscode/webview/src/components/nano/NanoActionHandler.ts b/mpp-vscode/webview/src/components/nano/NanoActionHandler.ts new file mode 100644 index 0000000000..6fcf8510d7 --- /dev/null +++ b/mpp-vscode/webview/src/components/nano/NanoActionHandler.ts @@ -0,0 +1,369 @@ +/** + * NanoUI Action Handler for React/TypeScript + * + * Handles NanoUI actions in a React environment. + * Provides platform-specific implementations for navigation, toast, and fetch. + * + * @see xuiper-ui/src/main/kotlin/cc/unitmesh/xuiper/action/NanoActionHandler.kt + */ + +// Types are defined locally to avoid circular dependencies + +// ============================================================================ +// Action Types +// ============================================================================ + +export type NanoAction = + | { type: 'StateMutation'; path: string; operation: MutationOp; value: any } + | { type: 'Navigate'; to: string } + | { type: 'Fetch'; url: string; method?: string; body?: Record; headers?: Record } + | { type: 'ShowToast'; message: string } + | { type: 'Sequence'; actions: NanoAction[] } + | { type: 'Custom'; name: string; payload: Record }; + +export type MutationOp = 'SET' | 'ADD' | 'SUBTRACT' | 'APPEND' | 'REMOVE'; + +export type ActionResult = + | { success: true; value?: any } + | { success: false; error: string }; + +// ============================================================================ +// Action Context +// ============================================================================ + +export interface NanoActionContext { + get(path: string): any; + set(path: string, value: any): void; + mutate(path: string, operation: MutationOp, value: any): void; + getState(): Record; +} + +// ============================================================================ +// Action Handler Interface +// ============================================================================ + +export interface NanoActionHandler { + handleAction(action: NanoAction, context: NanoActionContext): Promise; + handleStateMutation(action: Extract, context: NanoActionContext): ActionResult; + handleNavigate(action: Extract, context: NanoActionContext): ActionResult; + handleFetch(action: Extract, context: NanoActionContext): Promise; + handleShowToast(action: Extract, context: NanoActionContext): ActionResult; + handleSequence(action: Extract, context: NanoActionContext): Promise; + handleCustomAction(name: string, payload: Record, context: NanoActionContext): Promise; +} + +export type CustomActionHandler = ( + payload: Record, + context: NanoActionContext +) => Promise | ActionResult; + +// ============================================================================ +// React Action Handler Implementation +// ============================================================================ + +export interface ReactActionHandlerOptions { + onNavigate?: (to: string) => void; + onToast?: (message: string) => void; + onFetchComplete?: (url: string, success: boolean, data?: any) => void; + customActions?: Record; +} + +export class ReactActionHandler implements NanoActionHandler { + private customActions: Record = {}; + private options: ReactActionHandlerOptions; + + constructor(options: ReactActionHandlerOptions = {}) { + this.options = options; + if (options.customActions) { + this.customActions = { ...options.customActions }; + } + } + + registerCustomAction(name: string, handler: CustomActionHandler): void { + this.customActions[name] = handler; + } + + async handleAction(action: NanoAction, context: NanoActionContext): Promise { + switch (action.type) { + case 'StateMutation': + return this.handleStateMutation(action, context); + case 'Navigate': + return this.handleNavigate(action, context); + case 'Fetch': + return this.handleFetch(action, context); + case 'ShowToast': + return this.handleShowToast(action, context); + case 'Sequence': + return this.handleSequence(action, context); + case 'Custom': + return this.handleCustomAction(action.name, action.payload, context); + default: + return { success: false, error: `Unknown action type: ${(action as any).type}` }; + } + } + + handleStateMutation( + action: Extract, + context: NanoActionContext + ): ActionResult { + try { + context.mutate(action.path, action.operation, action.value); + return { success: true }; + } catch (e) { + return { success: false, error: `State mutation failed: ${e}` }; + } + } + + handleNavigate( + action: Extract, + _context: NanoActionContext + ): ActionResult { + try { + // Validate protocol before navigation + const allowedProtocols = ['http:', 'https:', 'mailto:']; + let url: URL; + try { + url = new URL(action.to, window.location.origin); + } catch (e) { + return { success: false, error: `Invalid URL: ${action.to}` }; + } + if (!allowedProtocols.includes(url.protocol)) { + return { success: false, error: `Unsafe protocol: ${url.protocol}` }; + } + + if (this.options.onNavigate) { + this.options.onNavigate(action.to); + } else { + // Default: use window.location for external URLs + if (url.protocol === 'http:' || url.protocol === 'https:') { + window.open(url.href, '_blank'); + } else { + window.location.href = url.href; + } + } + return { success: true }; + } catch (e) { + return { success: false, error: `Navigation failed: ${e}` }; + } + } + + async handleFetch( + action: Extract, + _context: NanoActionContext + ): Promise { + try { + const response = await fetch(action.url, { + method: action.method || 'GET', + headers: { + 'Content-Type': 'application/json', + ...action.headers, + }, + body: action.body ? JSON.stringify(action.body) : undefined, + }); + + const data = await response.json().catch(() => response.text()); + + if (response.ok) { + this.options.onFetchComplete?.(action.url, true, data); + return { success: true, value: data }; + } else { + const error = `HTTP ${response.status}: ${data}`; + this.options.onFetchComplete?.(action.url, false, error); + return { success: false, error }; + } + } catch (e) { + const error = `Fetch failed: ${e}`; + this.options.onFetchComplete?.(action.url, false, error); + return { success: false, error }; + } + } + + handleShowToast( + action: Extract, + _context: NanoActionContext + ): ActionResult { + try { + if (this.options.onToast) { + this.options.onToast(action.message); + } else { + // Default: use alert (not ideal but works) + alert(action.message); + } + return { success: true }; + } catch (e) { + return { success: false, error: `Toast failed: ${e}` }; + } + } + + async handleSequence( + action: Extract, + context: NanoActionContext + ): Promise { + for (const subAction of action.actions) { + const result = await this.handleAction(subAction, context); + if (!result.success) { + return result; + } + } + return { success: true }; + } + + async handleCustomAction( + name: string, + payload: Record, + context: NanoActionContext + ): Promise { + const handler = this.customActions[name]; + if (!handler) { + return { success: false, error: `Unknown custom action: ${name}` }; + } + + try { + const result = await handler(payload, context); + return result; + } catch (e) { + return { success: false, error: `Custom action '${name}' failed: ${e}` }; + } + } +} + +// ============================================================================ +// State Context Implementation +// ============================================================================ + +export class ReactStateContext implements NanoActionContext { + private state: Record; + private setState: React.Dispatch>>; + + constructor( + state: Record, + setState: React.Dispatch>> + ) { + this.state = state; + this.setState = setState; + } + + get(path: string): any { + const parts = path.split('.'); + let current: any = this.state; + + for (const part of parts) { + if (current == null) return undefined; + current = current[part]; + } + + return current; + } + + set(path: string, value: any): void { + this.setState(prev => { + const newState = { ...prev }; + this.setNestedValue(newState, path.split('.'), value); + return newState; + }); + } + + mutate(path: string, operation: MutationOp, value: any): void { + const currentValue = this.get(path); + let newValue: any; + + switch (operation) { + case 'SET': + newValue = value; + break; + case 'ADD': + if (typeof currentValue === 'number' && typeof value === 'number') { + newValue = currentValue + value; + } else if (typeof currentValue === 'string') { + newValue = currentValue + String(value); + } else { + newValue = value; + } + break; + case 'SUBTRACT': + if (typeof currentValue === 'number' && typeof value === 'number') { + newValue = currentValue - value; + } else { + newValue = currentValue; + } + break; + case 'APPEND': + if (Array.isArray(currentValue)) { + newValue = [...currentValue, value]; + } else { + newValue = [value]; + } + break; + case 'REMOVE': + if (Array.isArray(currentValue)) { + newValue = currentValue.filter(item => item !== value); + } else { + newValue = currentValue; + } + break; + default: + newValue = value; + } + + this.set(path, newValue); + } + + getState(): Record { + return { ...this.state }; + } + + private setNestedValue(obj: Record, parts: string[], value: any): void { + if (parts.length === 0) return; + + const key = parts[0]; + + if (parts.length === 1) { + obj[key] = value; + } else { + if (!(key in obj) || typeof obj[key] !== 'object') { + obj[key] = {}; + } + obj[key] = { ...obj[key] }; + this.setNestedValue(obj[key], parts.slice(1), value); + } + } +} + +// ============================================================================ +// Helper: Parse NanoIR action to NanoAction +// ============================================================================ + +export function parseNanoAction(actionIR: { type: string; payload?: Record }): NanoAction | null { + switch (actionIR.type) { + case 'StateMutation': { + const validOps: MutationOp[] = ['SET', 'ADD', 'SUBTRACT', 'APPEND', 'REMOVE']; + const op = actionIR.payload?.operation ?? 'SET'; + const operation: MutationOp = validOps.includes(op) ? (op as MutationOp) : 'SET'; + if (!validOps.includes(op)) { + console.warn(`Invalid mutation operation: ${op}, defaulting to SET`); + } + return { + type: 'StateMutation', + path: actionIR.payload?.path ?? '', + operation, + value: actionIR.payload?.value, + }; + } + case 'Navigate': + return { type: 'Navigate', to: actionIR.payload?.to ?? '' }; + case 'Fetch': + return { + type: 'Fetch', + url: actionIR.payload?.url ?? '', + method: actionIR.payload?.method, + body: actionIR.payload?.body, + headers: actionIR.payload?.headers, + }; + case 'ShowToast': + return { type: 'ShowToast', message: actionIR.payload?.message ?? '' }; + default: + // Treat as custom action + return { type: 'Custom', name: actionIR.type, payload: actionIR.payload ?? {} }; + } +} + diff --git a/mpp-vscode/webview/src/components/nano/index.ts b/mpp-vscode/webview/src/components/nano/index.ts index 0154a72be6..70b2045e7c 100644 --- a/mpp-vscode/webview/src/components/nano/index.ts +++ b/mpp-vscode/webview/src/components/nano/index.ts @@ -1,15 +1,15 @@ /** * NanoUI Renderer Components for VSCode Webview - * + * * Provides React components for rendering NanoIR to the VSCode webview. - * + * * @see xuiper-ui/src/main/kotlin/cc/unitmesh/xuiper/render/NanoRenderer.kt */ export { NanoRenderer } from './NanoRenderer'; -export type { - NanoIR, - NanoRenderContext, +export type { + NanoIR, + NanoRenderContext, NanoRenderAction, NanoTheme, NanoSpacingScale, @@ -17,3 +17,18 @@ export type { } from '../../types/nano'; export { DEFAULT_THEME } from '../../types/nano'; +// Action handling +export { + ReactActionHandler, + ReactStateContext, + parseNanoAction, +} from './NanoActionHandler'; +export type { + NanoAction, + NanoActionHandler, + NanoActionContext, + ActionResult, + MutationOp, + CustomActionHandler, +} from './NanoActionHandler'; + diff --git a/xuiper-ui/src/integrationTest/kotlin/cc/unitmesh/xuiper/integration/NanoDSLGenerationIntegrationTest.kt b/xuiper-ui/src/integrationTest/kotlin/cc/unitmesh/xuiper/integration/NanoDSLGenerationIntegrationTest.kt index a97eb5793a..3b8893097b 100644 --- a/xuiper-ui/src/integrationTest/kotlin/cc/unitmesh/xuiper/integration/NanoDSLGenerationIntegrationTest.kt +++ b/xuiper-ui/src/integrationTest/kotlin/cc/unitmesh/xuiper/integration/NanoDSLGenerationIntegrationTest.kt @@ -282,5 +282,72 @@ class NanoDSLGenerationIntegrationTest : NanoDSLIntegrationTestBase() { assertTrue(result is ParseResult.Success, "Generated DSL should be parseable. DSL:\n$generatedDsl") } + + // ==================== NAVIGATION ==================== + + @Test + @DisplayName("16 - Multi-Page Navigation: Generate app with multiple pages and navigation") + fun `16 multi page navigation should generate compilable dsl`() = runTest(timeout = 2.minutes) { + assumeConfigured() + + val userPrompt = """Create a simple multi-page app with: + a HomePage component with a welcome message and a button to navigate to /about, + an AboutPage component with company info and a Back button to navigate to /, + and a NavBar component at the top with links to both pages""" + val generatedDsl = generateDsl(userPrompt) + val result = verifyDslCompiles(generatedDsl, "16-multi-page-navigation") + + assertTrue(result is ParseResult.Success, + "Generated DSL should be parseable. DSL:\n$generatedDsl") + } + + @Test + @DisplayName("17 - Parameterized Route: Generate navigation with route parameters") + fun `17 parameterized route should generate compilable dsl`() = runTest(timeout = 2.minutes) { + assumeConfigured() + + val userPrompt = """Create a UserListItem component that displays user avatar, name, email, + and a View Profile button that navigates to /user/{id} with the user's id as a param. + Also create a UserProfile component that shows the user ID from state and has a + Back to List button that navigates to /users""" + val generatedDsl = generateDsl(userPrompt) + val result = verifyDslCompiles(generatedDsl, "17-parameterized-route") + + assertTrue(result is ParseResult.Success, + "Generated DSL should be parseable. DSL:\n$generatedDsl") + } + + @Test + @DisplayName("18 - Search with Query: Generate search page with query parameter navigation") + fun `18 search with query should generate compilable dsl`() = runTest(timeout = 2.minutes) { + assumeConfigured() + + val userPrompt = """Create a SearchPage component with search input, category filter dropdown, + and results list. The Search button should navigate to /search with query parameters + for search term (q), category, and page number. Include Previous/Next pagination buttons + that update the page query param""" + val generatedDsl = generateDsl(userPrompt) + val result = verifyDslCompiles(generatedDsl, "18-search-with-query") + + assertTrue(result is ParseResult.Success, + "Generated DSL should be parseable. DSL:\n$generatedDsl") + } + + @Test + @DisplayName("19 - Conditional Navigation: Generate login form with conditional navigation") + fun `19 conditional navigation should generate compilable dsl`() = runTest(timeout = 2.minutes) { + assumeConfigured() + + val userPrompt = """Create a LoginForm with email/password inputs. On login button click, + call /api/login API. On success, navigate to /dashboard with replace=true + (so user can't go back to login page). On error, show error message in a badge. + Include a Sign Up link that navigates to /signup and a Forgot Password link + that navigates to /forgot-password with the email as a query param""" + val generatedDsl = generateDsl(userPrompt) + val result = verifyDslCompiles(generatedDsl, "19-conditional-navigation") + + assertTrue(result is ParseResult.Success, + "Generated DSL should be parseable. DSL:\n$generatedDsl") + } } diff --git a/xuiper-ui/src/main/kotlin/cc/unitmesh/xuiper/action/NanoAction.kt b/xuiper-ui/src/main/kotlin/cc/unitmesh/xuiper/action/NanoAction.kt index 5f398e53d7..419ec2b873 100644 --- a/xuiper-ui/src/main/kotlin/cc/unitmesh/xuiper/action/NanoAction.kt +++ b/xuiper-ui/src/main/kotlin/cc/unitmesh/xuiper/action/NanoAction.kt @@ -20,12 +20,72 @@ sealed class NanoAction { ) : NanoAction() /** - * Navigation action - * Example: `on_click: Navigate(to="/cart")` + * Navigation action with enhanced routing support + * + * Examples: + * ```nanodsl + * # Simple navigation + * Navigate(to="/cart") + * + * # Navigation with route params + * Navigate(to="/user/{id}", params={"id": state.userId}) + * + * # Navigation with query string + * Navigate(to="/search", query={"q": state.query, "page": "1"}) + * + * # Replace current history entry (no back navigation) + * Navigate(to="/login", replace=true) + * + * # Full form + * Navigate( + * to="/product/{id}", + * params={"id": "123"}, + * query={"ref": "home"}, + * replace=false + * ) + * ``` */ data class Navigate( - val to: String - ) : NanoAction() + /** Target path (required) - can include path params like /user/{id} */ + val to: String, + + /** Route parameters to substitute in path - e.g., {"id": "123"} for /user/{id} */ + val params: Map? = null, + + /** Query parameters to append to URL - e.g., {"q": "search"} -> ?q=search */ + val query: Map? = null, + + /** Replace current history entry instead of pushing new one */ + val replace: Boolean = false + ) : NanoAction() { + /** + * Build the final URL with params and query string substituted + */ + fun buildUrl(): String { + var url = to + + // Substitute path params + params?.forEach { (key, value) -> + url = url.replace("{$key}", value) + url = url.replace(":$key", value) + } + + // Append query string + if (!query.isNullOrEmpty()) { + val queryString = query.entries.joinToString("&") { (k, v) -> + "${encodeURIComponent(k)}=${encodeURIComponent(v)}" + } + url = if (url.contains("?")) "$url&$queryString" else "$url?$queryString" + } + + return url + } + + private fun encodeURIComponent(value: String): String { + return java.net.URLEncoder.encode(value, "UTF-8") + .replace("+", "%20") + } + } /** * Network fetch action with comprehensive HTTP request support diff --git a/xuiper-ui/src/main/kotlin/cc/unitmesh/xuiper/action/NanoActionHandler.kt b/xuiper-ui/src/main/kotlin/cc/unitmesh/xuiper/action/NanoActionHandler.kt new file mode 100644 index 0000000000..16a81a4fee --- /dev/null +++ b/xuiper-ui/src/main/kotlin/cc/unitmesh/xuiper/action/NanoActionHandler.kt @@ -0,0 +1,231 @@ +package cc.unitmesh.xuiper.action + +/** + * Cross-platform action handler interface for NanoUI + * + * Provides a unified way to handle user interactions across different platforms + * (Compose, React, HTML). Each platform implements this interface to handle + * actions in a platform-specific way. + * + * Design follows the component-specific method pattern from NanoRenderer, + * ensuring compile-time safety when new action types are added. + * + * @see NanoAction for the sealed class of action types + * @see cc.unitmesh.xuiper.render.NanoRenderer for similar pattern + */ +interface NanoActionHandler { + + /** + * Main dispatch method - routes action to appropriate handler + * + * @param action The action to handle + * @param context The current render context with state + * @return Result of the action execution + */ + fun handleAction(action: NanoAction, context: NanoActionContext): ActionResult + + // ============================================================================ + // Built-in Action Handlers + // ============================================================================ + + /** + * Handle state mutation action + * Example: `state.count += 1` or `state.name = "John"` + */ + fun handleStateMutation(mutation: NanoAction.StateMutation, context: NanoActionContext): ActionResult + + /** + * Handle navigation action + * Example: `Navigate(to="/cart")` + */ + fun handleNavigate(navigate: NanoAction.Navigate, context: NanoActionContext): ActionResult + + /** + * Handle network fetch action + * Example: `Fetch(url="/api/users", method="POST", body={...})` + */ + fun handleFetch(fetch: NanoAction.Fetch, context: NanoActionContext): ActionResult + + /** + * Handle toast notification action + * Example: `ShowToast("Added to cart")` + */ + fun handleShowToast(toast: NanoAction.ShowToast, context: NanoActionContext): ActionResult + + /** + * Handle sequence of actions + * Executes multiple actions in order + */ + fun handleSequence(sequence: NanoAction.Sequence, context: NanoActionContext): ActionResult + + // ============================================================================ + // Custom Action Support + // ============================================================================ + + /** + * Handle custom user-defined action + * + * For actions like `AddTask(title=state.new_task)` or `DeleteTask(id=task.id)` + * that are not part of the built-in action set. + * + * @param name Action name (e.g., "AddTask", "DeleteTask") + * @param payload Action parameters + * @param context Current render context + * @return Result of the action execution + */ + fun handleCustomAction( + name: String, + payload: Map, + context: NanoActionContext + ): ActionResult +} + +/** + * Result of an action execution + */ +sealed class ActionResult { + /** + * Action completed successfully + */ + data object Success : ActionResult() + + /** + * Action completed successfully with a value + */ + data class SuccessWithValue(val value: Any?) : ActionResult() + + /** + * Action failed with an error + */ + data class Error(val message: String, val cause: Throwable? = null) : ActionResult() + + /** + * Action is pending (async operation in progress) + * The callback will be invoked when the action completes + */ + data class Pending(val onComplete: (ActionResult) -> Unit) : ActionResult() + + /** + * Check if the result is successful + */ + val isSuccess: Boolean + get() = this is Success || this is SuccessWithValue + + /** + * Check if the result is an error + */ + val isError: Boolean + get() = this is Error +} + +/** + * Context passed to action handlers + * + * Contains the current state and methods to mutate it + */ +interface NanoActionContext { + /** + * Get a value from state by path + * Example: get("user.name") returns the user's name + */ + operator fun get(path: String): Any? + + /** + * Set a value in state by path + * Example: set("user.name", "John") + */ + operator fun set(path: String, value: Any?) + + /** + * Apply a mutation operation to state + */ + fun mutate(path: String, operation: MutationOp, value: Any?) + + /** + * Get the entire state as a map + */ + fun getState(): Map +} + +/** + * Registry for custom action handlers + * + * Allows users to register handlers for custom actions like AddTask, DeleteTask + */ +typealias CustomActionHandler = (payload: Map, context: NanoActionContext) -> ActionResult + +/** + * Default implementation of NanoActionHandler + * + * Provides a base implementation that can be extended by platform-specific handlers. + * Routes actions to the appropriate handler method. + */ +abstract class BaseNanoActionHandler : NanoActionHandler { + + private val customHandlers = mutableMapOf() + + /** + * Register a custom action handler + */ + fun registerCustomAction(name: String, handler: CustomActionHandler) { + customHandlers[name] = handler + } + + /** + * Register multiple custom action handlers + */ + fun registerCustomActions(handlers: Map) { + customHandlers.putAll(handlers) + } + + override fun handleAction(action: NanoAction, context: NanoActionContext): ActionResult { + return when (action) { + is NanoAction.StateMutation -> handleStateMutation(action, context) + is NanoAction.Navigate -> handleNavigate(action, context) + is NanoAction.Fetch -> handleFetch(action, context) + is NanoAction.ShowToast -> handleShowToast(action, context) + is NanoAction.Sequence -> handleSequence(action, context) + } + } + + override fun handleStateMutation( + mutation: NanoAction.StateMutation, + context: NanoActionContext + ): ActionResult { + return try { + context.mutate(mutation.path, mutation.operation, mutation.value) + ActionResult.Success + } catch (e: Exception) { + ActionResult.Error("Failed to mutate state: ${e.message}", e) + } + } + + override fun handleSequence( + sequence: NanoAction.Sequence, + context: NanoActionContext + ): ActionResult { + for (action in sequence.actions) { + val result = handleAction(action, context) + if (result.isError) { + return result + } + } + return ActionResult.Success + } + + override fun handleCustomAction( + name: String, + payload: Map, + context: NanoActionContext + ): ActionResult { + val handler = customHandlers[name] + ?: return ActionResult.Error("Unknown custom action: $name") + + return try { + handler(payload, context) + } catch (e: Exception) { + ActionResult.Error("Custom action '$name' failed: ${e.message}", e) + } + } +} + diff --git a/xuiper-ui/src/main/kotlin/cc/unitmesh/xuiper/parser/IndentParser.kt b/xuiper-ui/src/main/kotlin/cc/unitmesh/xuiper/parser/IndentParser.kt index 39b3fe22e5..e465e94ab0 100644 --- a/xuiper-ui/src/main/kotlin/cc/unitmesh/xuiper/parser/IndentParser.kt +++ b/xuiper-ui/src/main/kotlin/cc/unitmesh/xuiper/parser/IndentParser.kt @@ -194,11 +194,18 @@ class IndentParser( val match = STATE_VAR_REGEX.matchEntire(line) if (match != null) { + val rawValue = match.groupValues[3].trim() + // Strip quotes from string values (e.g., "" -> empty string, "hello" -> hello) + val defaultValue = if (rawValue.startsWith("\"") && rawValue.endsWith("\"")) { + rawValue.substring(1, rawValue.length - 1) + } else { + rawValue + } variables.add( NanoNode.StateVariable( name = match.groupValues[1], type = match.groupValues[2], - defaultValue = match.groupValues[3].trim() + defaultValue = defaultValue ) ) } @@ -284,6 +291,17 @@ class IndentParser( continue } + // Check if this is a component call (e.g., "VStack:" or "Button(...):") BEFORE checking property + // This prevents "VStack:" from being matched as an empty property + if (COMPONENT_CALL_REGEX.matches(trimmed) || COMPONENT_INLINE_REGEX.matches(trimmed)) { + val (node, newIndex) = parseNode(lines, index, currentIndent) + if (node != null) { + children.add(node) + } + index = newIndex + continue + } + // Check for property PROP_REGEX.matchEntire(trimmed)?.let { match -> val propName = match.groupValues[1] @@ -599,12 +617,9 @@ class IndentParser( private fun parseAction(actionStr: String): NanoAction { val trimmed = actionStr.trim() - // Navigate action + // Navigate action (enhanced with params, query, replace) if (trimmed.startsWith("Navigate(")) { - val toMatch = Regex("""Navigate\(to="(.+?)"\)""").find(trimmed) - if (toMatch != null) { - return NanoAction.Navigate(toMatch.groupValues[1]) - } + return parseNavigateAction(trimmed) } // ShowToast action @@ -636,6 +651,64 @@ class IndentParser( return NanoAction.StateMutation("unknown", MutationOp.SET, trimmed) } + /** + * Parse Navigate action with enhanced routing support + * Examples: + * - `Navigate(to="/home")` + * - `Navigate(to="/user/{id}", params={"id": state.userId})` + * - `Navigate(to="/search", query={"q": state.query})` + * - `Navigate(to="/login", replace=true)` + */ + private fun parseNavigateAction(actionStr: String): NanoAction.Navigate { + val paramsStr = actionStr.removePrefix("Navigate(").removeSuffix(")") + + // Parse 'to' (required) + val toMatch = Regex("""to\s*=\s*"([^"]+)"""").find(paramsStr) + val to = toMatch?.groupValues?.get(1) ?: "/" + + // Parse 'replace' boolean + val replaceMatch = Regex("""replace\s*=\s*(true|false)""").find(paramsStr) + val replace = replaceMatch?.groupValues?.get(1)?.toBoolean() ?: false + + // Parse 'params' map: params={"id": state.userId} or params={"id": "123"} + val routeParams = parseNavigateMap(paramsStr, "params") + + // Parse 'query' map: query={"q": state.query} + val queryParams = parseNavigateMap(paramsStr, "query") + + return NanoAction.Navigate( + to = to, + params = routeParams, + query = queryParams, + replace = replace + ) + } + + /** + * Parse a map parameter from Navigate action + * Supports: {"key": "value"} or {"key": state.path} + */ + private fun parseNavigateMap(paramsStr: String, mapName: String): Map? { + val mapMatch = Regex("""$mapName\s*=\s*\{([^}]*)\}""").find(paramsStr) ?: return null + val mapContent = mapMatch.groupValues[1] + + val result = mutableMapOf() + // Match patterns like "id": "123" or "id": state.userId + val fieldRegex = Regex(""""(\w+)"\s*:\s*("([^"]*)"|state\.[\w.]+)""") + fieldRegex.findAll(mapContent).forEach { match -> + val key = match.groupValues[1] + val value = match.groupValues[2] + // Remove quotes if it's a literal string + result[key] = if (value.startsWith("\"")) { + value.trim('"') + } else { + value // Keep state.xxx as-is for runtime binding + } + } + + return result.takeIf { it.isNotEmpty() } + } + /** * Parse Fetch action with parameters * Examples: @@ -733,10 +806,22 @@ class IndentParser( children = children ) "Text" -> { - val content = extractFirstArg(argsStr) ?: "" + val contentArg = args["content"] + val content = if (contentArg != null) { + // If content is a binding, extract the expression + if (contentArg.startsWith("<<") || contentArg.startsWith(":=")) { + "" // Content will come from binding + } else { + contentArg + } + } else { + extractFirstArg(argsStr) ?: "" + } + val binding = contentArg?.let { Binding.parse(it) }?.takeIf { it !is Binding.Static } NanoNode.Text( content = content, - style = args["style"] ?: props["style"] + style = args["style"] ?: props["style"], + binding = binding ) } "Button" -> { @@ -802,11 +887,23 @@ class IndentParser( if (argsStr.isBlank()) return emptyMap() val result = mutableMapOf() + + // First, handle << (subscribe binding) pattern: content << state.count + val subscribeRegex = Regex("""(\w+)\s*<<\s*([\w.]+)""") + subscribeRegex.findAll(argsStr).forEach { match -> + val key = match.groupValues[1] + val value = match.groupValues[2] + result[key] = "<< $value" + } + + // Then handle := (two-way binding) and = (assignment) patterns // Match patterns like: name="value", name=value, name := state.path, name: "value" - // Support both := (two-way binding) and = (assignment) val argRegex = Regex("""(\w+)\s*(?::=|=|:)\s*(?:"([^"]*)"|([\w.]+))""") argRegex.findAll(argsStr).forEach { match -> val key = match.groupValues[1] + // Skip if already handled by subscribe binding + if (key in result) return@forEach + val rawValue = match.groupValues[2].ifEmpty { match.groupValues[3] } // Preserve the binding operator for value parsing diff --git a/xuiper-ui/src/main/kotlin/cc/unitmesh/xuiper/render/HtmlRenderer.kt b/xuiper-ui/src/main/kotlin/cc/unitmesh/xuiper/render/HtmlRenderer.kt index 6e9a3b7067..9c42c53ee5 100644 --- a/xuiper-ui/src/main/kotlin/cc/unitmesh/xuiper/render/HtmlRenderer.kt +++ b/xuiper-ui/src/main/kotlin/cc/unitmesh/xuiper/render/HtmlRenderer.kt @@ -146,8 +146,14 @@ class HtmlRenderer( val label = ir.props["label"]?.jsonPrimitive?.content ?: "" val intent = ir.props["intent"]?.jsonPrimitive?.content ?: "default" val icon = ir.props["icon"]?.jsonPrimitive?.content + val actionAttr = renderActionAttribute(ir) + val bindingAttr = renderBindingAttribute(ir) + return buildString { - append("\n") @@ -157,29 +163,65 @@ class HtmlRenderer( override fun renderInput(ir: NanoIR): String { val placeholder = ir.props["placeholder"]?.jsonPrimitive?.content ?: "" val type = ir.props["type"]?.jsonPrimitive?.content ?: "text" - return "\n" + val bindingAttr = renderBindingAttribute(ir) + val actionAttr = renderActionAttribute(ir) + + return buildString { + append("\n") + } } override fun renderCheckbox(ir: NanoIR): String { val label = ir.props["label"]?.jsonPrimitive?.content + val bindingAttr = renderBindingAttribute(ir) + val actionAttr = renderActionAttribute(ir) + return if (label != null) { - "\n" + buildString { + append("\n") + } } else { - "\n" + buildString { + append("\n") + } } } override fun renderTextArea(ir: NanoIR): String { val placeholder = ir.props["placeholder"]?.jsonPrimitive?.content ?: "" val rows = ir.props["rows"]?.jsonPrimitive?.content ?: "4" - return "\n" + val bindingAttr = renderBindingAttribute(ir) + val actionAttr = renderActionAttribute(ir) + + return buildString { + append("\n") + } } override fun renderSelect(ir: NanoIR): String { val placeholder = ir.props["placeholder"]?.jsonPrimitive?.content val options = ir.props["options"]?.jsonPrimitive?.content + val bindingAttr = renderBindingAttribute(ir) + val actionAttr = renderActionAttribute(ir) + return buildString { - append("\n") if (placeholder != null) { append(" \n") } @@ -231,6 +273,65 @@ class HtmlRenderer( return "\n" } + // ============================================================================ + // Action & Binding Helpers + // ============================================================================ + + /** + * Render data-action attribute for components with actions + * + * Example output: data-action='{"on_click":{"type":"StateMutation","payload":{...}}}' + */ + private fun renderActionAttribute(ir: NanoIR): String { + val actions = ir.actions ?: return "" + if (actions.isEmpty()) return "" + + val actionsJson = buildString { + append("{") + actions.entries.forEachIndexed { index, (event, action) -> + if (index > 0) append(",") + append("\"$event\":{") + append("\"type\":\"${action.type}\"") + action.payload?.let { payload -> + append(",\"payload\":{") + payload.entries.forEachIndexed { pIndex, (key, value) -> + if (pIndex > 0) append(",") + append("\"$key\":$value") + } + append("}") + } + append("}") + } + append("}") + } + + return " data-actions='$actionsJson'" + } + + /** + * Render data-binding attribute for bound components + * + * Example output: data-bindings='{"value":{"mode":"twoWay","expression":"state.new_task"}}' + */ + private fun renderBindingAttribute(ir: NanoIR): String { + val bindings = ir.bindings ?: return "" + if (bindings.isEmpty()) return "" + + val bindingsJson = buildString { + append("{") + bindings.entries.forEachIndexed { index, (prop, binding) -> + if (index > 0) append(",") + append("\"$prop\":{") + append("\"mode\":\"${binding.mode}\",") + append("\"expression\":\"${binding.expression}\"") + append("}") + } + append("}") + } + + return " data-bindings='$bindingsJson'" + } + private fun generateCss(): String = """ * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } diff --git a/xuiper-ui/src/main/kotlin/cc/unitmesh/xuiper/render/NanoRenderContext.kt b/xuiper-ui/src/main/kotlin/cc/unitmesh/xuiper/render/NanoRenderContext.kt new file mode 100644 index 0000000000..d3e9360132 --- /dev/null +++ b/xuiper-ui/src/main/kotlin/cc/unitmesh/xuiper/render/NanoRenderContext.kt @@ -0,0 +1,85 @@ +package cc.unitmesh.xuiper.render + +import cc.unitmesh.xuiper.action.ActionResult +import cc.unitmesh.xuiper.action.CustomActionHandler +import cc.unitmesh.xuiper.action.NanoAction +import cc.unitmesh.xuiper.action.NanoActionHandler +import cc.unitmesh.xuiper.state.NanoState + +/** + * Render context for NanoUI components + * + * Bundles together all the context needed for rendering: + * - State management + * - Action handling + * - Theme configuration + * + * Example: + * ```kotlin + * val context = NanoRenderContext( + * state = NanoState(mapOf("count" to 0)), + * actionHandler = ComposeActionHandler(), + * theme = NanoTheme.default() + * ) + * + * val html = HtmlRenderer(context).render(ir) + * ``` + */ +data class NanoRenderContext( + /** + * Reactive state container + */ + val state: NanoState, + + /** + * Action handler for user interactions + */ + val actionHandler: NanoActionHandler, + + /** + * Theme configuration + */ + val theme: NanoTheme = NanoTheme.Default +) { + /** + * Dispatch an action through the handler + */ + fun dispatch(action: NanoAction): ActionResult { + return actionHandler.handleAction(action, state) + } + + /** + * Register a custom action handler + */ + fun registerAction(name: String, handler: CustomActionHandler) { + if (actionHandler is cc.unitmesh.xuiper.action.BaseNanoActionHandler) { + actionHandler.registerCustomAction(name, handler) + } + } + + companion object { + /** + * Create a minimal context for static rendering (no actions) + */ + fun static(): NanoRenderContext { + return NanoRenderContext( + state = NanoState(), + actionHandler = NoOpActionHandler + ) + } + } +} + +/** + * No-op action handler for static rendering + */ +private object NoOpActionHandler : NanoActionHandler { + override fun handleAction(action: NanoAction, context: cc.unitmesh.xuiper.action.NanoActionContext) = ActionResult.Success + override fun handleStateMutation(mutation: NanoAction.StateMutation, context: cc.unitmesh.xuiper.action.NanoActionContext) = ActionResult.Success + override fun handleNavigate(navigate: NanoAction.Navigate, context: cc.unitmesh.xuiper.action.NanoActionContext) = ActionResult.Success + override fun handleFetch(fetch: NanoAction.Fetch, context: cc.unitmesh.xuiper.action.NanoActionContext) = ActionResult.Success + override fun handleShowToast(toast: NanoAction.ShowToast, context: cc.unitmesh.xuiper.action.NanoActionContext) = ActionResult.Success + override fun handleSequence(sequence: NanoAction.Sequence, context: cc.unitmesh.xuiper.action.NanoActionContext) = ActionResult.Success + override fun handleCustomAction(name: String, payload: Map, context: cc.unitmesh.xuiper.action.NanoActionContext) = ActionResult.Success +} + diff --git a/xuiper-ui/src/main/kotlin/cc/unitmesh/xuiper/state/NanoState.kt b/xuiper-ui/src/main/kotlin/cc/unitmesh/xuiper/state/NanoState.kt index cbde12461f..726b008ce3 100644 --- a/xuiper-ui/src/main/kotlin/cc/unitmesh/xuiper/state/NanoState.kt +++ b/xuiper-ui/src/main/kotlin/cc/unitmesh/xuiper/state/NanoState.kt @@ -1,28 +1,35 @@ package cc.unitmesh.xuiper.state +import cc.unitmesh.xuiper.action.MutationOp +import cc.unitmesh.xuiper.action.NanoActionContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow /** * NanoState - Reactive state container for NanoDSL - * + * * Provides observable state that can be bound to UI components. * Supports both one-way subscription (<<) and two-way binding (:=). - * + * Implements NanoActionContext for action handler integration. + * * Example usage: * ```kotlin * val state = NanoState(mapOf("count" to 0, "name" to "")) - * + * * // Subscribe to changes * state.subscribe("count") { value -> println("Count: $value") } - * + * * // Update state * state.set("count", 1) * state.update("count") { (it as Int) + 1 } + * + * // Mutation operations + * state.mutate("count", MutationOp.ADD, 5) + * state.mutate("items", MutationOp.APPEND, newItem) * ``` */ -class NanoState(initialState: Map = emptyMap()) { +class NanoState(initialState: Map = emptyMap()) : NanoActionContext { private val _states = mutableMapOf>() private val subscribers = mutableMapOf Unit>>() @@ -33,22 +40,6 @@ class NanoState(initialState: Map = emptyMap()) { } } - /** - * Get current value of a state variable - */ - operator fun get(path: String): Any? { - return _states[path]?.value - } - - /** - * Set value of a state variable - */ - operator fun set(path: String, value: Any?) { - val flow = _states.getOrPut(path) { MutableStateFlow(value) } - flow.value = value - notifySubscribers(path, value) - } - /** * Get StateFlow for a path (for Compose integration) */ @@ -124,6 +115,136 @@ class NanoState(initialState: Map = emptyMap()) { callback(value) } } + + // ============================================================================ + // NanoActionContext Implementation + // ============================================================================ + + override operator fun get(path: String): Any? { + // Support nested path like "user.name" + if (!path.contains(".")) { + return _states[path]?.value + } + + val parts = path.split(".") + var current: Any? = _states[parts.first()]?.value + + for (i in 1 until parts.size) { + current = when (current) { + is Map<*, *> -> current[parts[i]] + is List<*> -> { + val index = parts[i].toIntOrNull() + if (index != null && index in current.indices) current[index] else null + } + else -> null + } + if (current == null) break + } + + return current + } + + override operator fun set(path: String, value: Any?) { + if (!path.contains(".")) { + val flow = _states.getOrPut(path) { MutableStateFlow(value) } + flow.value = value + notifySubscribers(path, value) + return + } + + // Handle nested path + val parts = path.split(".") + val rootKey = parts.first() + val rootValue = _states[rootKey]?.value + + val newRootValue = setNestedValue( + when (rootValue) { + is Map<*, *> -> @Suppress("UNCHECKED_CAST") (rootValue as Map).toMutableMap() + else -> mutableMapOf() + }, + parts.drop(1), + value + ) + + val flow = _states.getOrPut(rootKey) { MutableStateFlow(newRootValue) } + flow.value = newRootValue + notifySubscribers(rootKey, newRootValue) + } + + override fun mutate(path: String, operation: MutationOp, value: Any?) { + val currentValue = get(path) + + val newValue = when (operation) { + MutationOp.SET -> value + + MutationOp.ADD -> { + when { + currentValue is Number && value is Number -> + currentValue.toDouble() + value.toDouble() + currentValue is String && value != null -> + currentValue + value.toString() + else -> value + } + } + + MutationOp.SUBTRACT -> { + if (currentValue is Number && value is Number) { + currentValue.toDouble() - value.toDouble() + } else currentValue + } + + MutationOp.APPEND -> { + when (currentValue) { + is List<*> -> currentValue + value + is MutableList<*> -> { + @Suppress("UNCHECKED_CAST") + (currentValue as MutableList).also { it.add(value) } + } + null -> listOf(value) + else -> listOf(currentValue, value) + } + } + + MutationOp.REMOVE -> { + when (currentValue) { + is List<*> -> currentValue - value + is MutableList<*> -> { + @Suppress("UNCHECKED_CAST") + (currentValue as MutableList).also { it.remove(value) } + } + else -> currentValue + } + } + } + + set(path, newValue) + } + + override fun getState(): Map = snapshot() + + private fun setNestedValue( + map: MutableMap, + parts: List, + value: Any? + ): Map { + if (parts.isEmpty()) return map + + val key = parts.first() + + if (parts.size == 1) { + map[key] = value + } else { + val nested = map[key] + val nestedMap = when (nested) { + is MutableMap<*, *> -> @Suppress("UNCHECKED_CAST") (nested as MutableMap) + is Map<*, *> -> @Suppress("UNCHECKED_CAST") (nested as Map).toMutableMap() + else -> mutableMapOf() + } + map[key] = setNestedValue(nestedMap, parts.drop(1), value) + } + + return map + } } /** diff --git a/xuiper-ui/src/main/resources/prompts/detailed.txt b/xuiper-ui/src/main/resources/prompts/detailed.txt index ab6a012c51..cd85af3457 100644 --- a/xuiper-ui/src/main/resources/prompts/detailed.txt +++ b/xuiper-ui/src/main/resources/prompts/detailed.txt @@ -109,7 +109,42 @@ request login: HTTP Methods: `GET`, `POST`, `PUT`, `PATCH`, `DELETE` Content Types: `JSON`, `FORM_URLENCODED`, `FORM_DATA` -### 9. Size/Style Tokens +### 9. Routing & Navigation +```nanodsl +# Simple navigation +Button("Go Home"): + on_click: Navigate(to="/home") + +# Navigation with route params (substitutes {id} in path) +Button("View Profile"): + on_click: Navigate(to="/user/{id}", params={"id": state.userId}) + +# Navigation with query string (?q=search&page=1) +Button("Search"): + on_click: Navigate(to="/search", query={"q": state.query, "page": "1"}) + +# Replace history (no back button to previous page) +Button("Login"): + on_click: Navigate(to="/dashboard", replace=true) + +# Full navigation with all options +Button("Go to Product"): + on_click: + Navigate( + to="/product/{id}", + params={"id": state.productId}, + query={"ref": "home"}, + replace=false + ) +``` + +Navigation Parameters: +- `to`: Target path (required) - supports path params like `/user/{id}` or `/user/:id` +- `params`: Route parameters to substitute in path +- `query`: Query string parameters to append +- `replace`: If true, replaces current history entry (user cannot go back) + +### 10. Size/Style Tokens - Sizes: `"xs"`, `"sm"`, `"md"`, `"lg"`, `"xl"` - Text styles: `"h1"`, `"h2"`, `"h3"`, `"body"`, `"caption"` - Intents: `"primary"`, `"secondary"`, `"danger"`, `"success"` diff --git a/xuiper-ui/src/main/resources/prompts/standard.txt b/xuiper-ui/src/main/resources/prompts/standard.txt index ec978a822a..1b4bc5e56f 100644 --- a/xuiper-ui/src/main/resources/prompts/standard.txt +++ b/xuiper-ui/src/main/resources/prompts/standard.txt @@ -32,11 +32,33 @@ state: - `:=` - Two-way binding ### Actions -- `on_click: state.var += 1` - State mutation -- `Navigate(to="/path")` - Navigation +- `on_click: state.var += 1` - State mutation (increment) +- `on_click: state.var -= 1` - State mutation (decrement) +- `on_click: state.var = value` - State mutation (set) +- `Navigate(to="/path")` - Simple navigation +- `Navigate(to="/user/{id}", params={"id": state.userId})` - Navigation with route params +- `Navigate(to="/search", query={"q": state.query})` - Navigation with query string +- `Navigate(to="/login", replace=true)` - Replace history (no back) - `ShowToast("message")` - Show notification - `Fetch(url="/api/...", method="POST", body={...})` - HTTP request +### Counter Example (Interactive) +``` +component Counter: + state: + count: int = 0 + + Card(padding="lg"): + VStack(spacing="md"): + Text("Counter", style="h2") + HStack(spacing="md", align="center", justify="center"): + Button("-", intent="secondary"): + on_click: state.count -= 1 + Text(content << state.count, style="h1") + Button("+", intent="primary"): + on_click: state.count += 1 +``` + ### HTTP Requests ``` # Inline fetch with callbacks diff --git a/xuiper-ui/src/test/kotlin/cc/unitmesh/xuiper/dsl/NanoDSLParserTest.kt b/xuiper-ui/src/test/kotlin/cc/unitmesh/xuiper/dsl/NanoDSLParserTest.kt index 3976069c75..b7fba0cee1 100644 --- a/xuiper-ui/src/test/kotlin/cc/unitmesh/xuiper/dsl/NanoDSLParserTest.kt +++ b/xuiper-ui/src/test/kotlin/cc/unitmesh/xuiper/dsl/NanoDSLParserTest.kt @@ -3,6 +3,7 @@ package cc.unitmesh.xuiper.dsl import cc.unitmesh.xuiper.action.HttpMethod import cc.unitmesh.xuiper.action.NanoAction import cc.unitmesh.xuiper.ast.NanoNode +import kotlinx.serialization.json.jsonPrimitive import org.junit.jupiter.api.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -390,4 +391,74 @@ component ContactForm: assertEquals("/api/contact", fetchAction!!.url) assertEquals(HttpMethod.POST, fetchAction.method) } + + @Test + fun `should parse state variables with empty string default values correctly`() { + val source = """ +component LoginForm: + state: + email: str = "" + password: str = "" + username: str = "default" + loading: bool = False + count: int = 0 + + VStack: + Text("Login") + """.trimIndent() + + val result = NanoDSL.parse(source) + + // Check state was parsed + assertNotNull(result.state) + val state = result.state!! + + // Check that empty string default values are correctly stripped of quotes + val emailVar = state.variables.find { it.name == "email" } + assertNotNull(emailVar) + assertEquals("str", emailVar!!.type) + assertEquals("", emailVar.defaultValue, "Empty string should have quotes stripped") + + val passwordVar = state.variables.find { it.name == "password" } + assertNotNull(passwordVar) + assertEquals("", passwordVar!!.defaultValue, "Empty string should have quotes stripped") + + // Check that non-empty string values are also correctly stripped + val usernameVar = state.variables.find { it.name == "username" } + assertNotNull(usernameVar) + assertEquals("default", usernameVar!!.defaultValue, "String value should have quotes stripped") + + // Check that non-string values are preserved as-is + val loadingVar = state.variables.find { it.name == "loading" } + assertNotNull(loadingVar) + assertEquals("False", loadingVar!!.defaultValue) + + val countVar = state.variables.find { it.name == "count" } + assertNotNull(countVar) + assertEquals("0", countVar!!.defaultValue) + } + + @Test + fun `should render state with empty string correctly in IR`() { + val source = """ +component LoginForm: + state: + email: str = "" + password: str = "" + + Card: + Input(value:=state.email, placeholder="Email") + """.trimIndent() + + val result = NanoDSL.parse(source) + val ir = NanoDSL.toIR(result) + + // Check state in IR + assertNotNull(ir.state) + val emailState = ir.state!!.variables["email"] + assertNotNull(emailState) + assertEquals("str", emailState!!.type) + // The default value should be an empty string, not "" + assertEquals("", emailState.defaultValue?.jsonPrimitive?.content) + } } diff --git a/xuiper-ui/testcases/expect/16-multi-page-navigation.nanodsl b/xuiper-ui/testcases/expect/16-multi-page-navigation.nanodsl new file mode 100644 index 0000000000..f51ec14de5 --- /dev/null +++ b/xuiper-ui/testcases/expect/16-multi-page-navigation.nanodsl @@ -0,0 +1,29 @@ +component HomePage: + Card: + padding: "lg" + content: + VStack(spacing="md"): + Text("Welcome Home", style="h1") + Text("This is the home page of our application.", style="body") + Button("Learn About Us", intent="primary"): + on_click: Navigate(to="/about") + +component AboutPage: + Card: + padding: "lg" + content: + VStack(spacing="md"): + Text("About Us", style="h1") + Text("We are a company dedicated to building great products.", style="body") + Button("Back to Home", intent="secondary"): + on_click: Navigate(to="/") + +component NavBar: + HStack(justify="between", align="center"): + Text("MyApp", style="h3") + HStack(spacing="md"): + Button("Home"): + on_click: Navigate(to="/") + Button("About"): + on_click: Navigate(to="/about") + diff --git a/xuiper-ui/testcases/expect/17-parameterized-route.nanodsl b/xuiper-ui/testcases/expect/17-parameterized-route.nanodsl new file mode 100644 index 0000000000..80c8910ed9 --- /dev/null +++ b/xuiper-ui/testcases/expect/17-parameterized-route.nanodsl @@ -0,0 +1,25 @@ +component UserListItem(user: User): + Card: + padding: "sm" + content: + HStack(spacing="md", align="center"): + Image(src=user.avatar, radius="full") + VStack(spacing="xs"): + Text(user.name, style="body") + Text(user.email, style="caption") + Button("View Profile", intent="primary"): + on_click: Navigate(to="/user/{id}", params={"id": user.id}) + +component UserProfile: + state: + user_id: str = "" + + Card: + padding: "lg" + content: + VStack(spacing="md"): + Text("User Profile", style="h1") + Text(content << f"User ID: {state.user_id}", style="body") + Button("Back to List", intent="secondary"): + on_click: Navigate(to="/users") + diff --git a/xuiper-ui/testcases/expect/18-search-with-query.nanodsl b/xuiper-ui/testcases/expect/18-search-with-query.nanodsl new file mode 100644 index 0000000000..751ea391c5 --- /dev/null +++ b/xuiper-ui/testcases/expect/18-search-with-query.nanodsl @@ -0,0 +1,49 @@ +component SearchPage: + state: + query: str = "" + category: str = "all" + page: int = 1 + results: list = [] + + Card: + padding: "lg" + content: + VStack(spacing="md"): + Text("Search Products", style="h1") + + HStack(spacing="sm"): + Input(value := state.query, placeholder="Search...") + Select(value := state.category, options=["all", "electronics", "clothing"]) + Button("Search", intent="primary"): + on_click: + Navigate( + to="/search", + query={"q": state.query, "category": state.category, "page": "1"} + ) + + VStack(spacing="sm"): + for item in state.results: + Card: + padding: "sm" + content: + HStack(spacing="md"): + Image(src=item.image) + VStack: + Text(item.name, style="body") + Text(item.price, style="caption") + + HStack(spacing="sm", justify="center"): + Button("Previous"): + on_click: + Navigate( + to="/search", + query={"q": state.query, "category": state.category, "page": state.page - 1} + ) + Text(content << f"Page {state.page}", style="body") + Button("Next"): + on_click: + Navigate( + to="/search", + query={"q": state.query, "category": state.category, "page": state.page + 1} + ) + diff --git a/xuiper-ui/testcases/expect/19-conditional-navigation.nanodsl b/xuiper-ui/testcases/expect/19-conditional-navigation.nanodsl new file mode 100644 index 0000000000..a4f0c1fc20 --- /dev/null +++ b/xuiper-ui/testcases/expect/19-conditional-navigation.nanodsl @@ -0,0 +1,44 @@ +component LoginForm: + state: + email: str = "" + password: str = "" + is_loading: bool = False + error: str = "" + + Card: + padding: "lg" + shadow: "md" + content: + VStack(spacing="md"): + Text("Sign In", style="h2") + + if state.error: + Badge(state.error, color="red") + + Input(value := state.email, placeholder="Email") + Input(value := state.password, placeholder="Password", type="password") + + Button("Login", intent="primary"): + on_click: + state.is_loading = True + state.error = "" + Fetch( + url="/api/login", + method="POST", + body={"email": state.email, "password": state.password}, + on_success: + state.is_loading = False + Navigate(to="/dashboard", replace=true) + on_error: + state.is_loading = False + state.error = "Invalid credentials" + ) + + HStack(justify="center", spacing="sm"): + Text("Don't have an account?", style="caption") + Button("Sign Up", intent="secondary"): + on_click: Navigate(to="/signup") + + Button("Forgot Password?", intent="secondary"): + on_click: Navigate(to="/forgot-password", query={"email": state.email}) + diff --git a/xuiper-ui/testcases/nanodsl-eval-suite.json b/xuiper-ui/testcases/nanodsl-eval-suite.json index 7533043fd7..e4eab055c5 100644 --- a/xuiper-ui/testcases/nanodsl-eval-suite.json +++ b/xuiper-ui/testcases/nanodsl-eval-suite.json @@ -162,6 +162,46 @@ "category": "COMPOSITE", "difficulty": "HARD", "tags": ["cart", "state", "http", "calculation", "for-loop"] + }, + { + "id": "multi-page-navigation", + "name": "Multi-Page Navigation", + "description": "Generate a multi-page app with simple navigation between pages", + "userPrompt": "Create a simple multi-page app with: a HomePage component with a welcome message and a button to navigate to /about, an AboutPage component with company info and a Back button to navigate to /, and a NavBar component at the top with links to both pages", + "expectedDslFile": "expect/16-multi-page-navigation.nanodsl", + "category": "NAVIGATION", + "difficulty": "EASY", + "tags": ["navigation", "multi-page", "button"] + }, + { + "id": "parameterized-route", + "name": "Parameterized Route Navigation", + "description": "Generate components that navigate with dynamic route parameters", + "userPrompt": "Create a UserListItem component that displays user avatar, name, email, and a View Profile button that navigates to /user/{id} with the user's id as a param. Also create a UserProfile component that shows the user ID from state and has a Back to List button", + "expectedDslFile": "expect/17-parameterized-route.nanodsl", + "category": "NAVIGATION", + "difficulty": "MEDIUM", + "tags": ["navigation", "params", "route-params"] + }, + { + "id": "search-with-query", + "name": "Search Page with Query Navigation", + "description": "Generate a search page that uses query parameters for filtering and pagination", + "userPrompt": "Create a SearchPage component with search input, category filter, and results list. The Search button should navigate to /search with query parameters for search term, category, and page number. Include Previous/Next pagination buttons that update the page query param", + "expectedDslFile": "expect/18-search-with-query.nanodsl", + "category": "NAVIGATION", + "difficulty": "MEDIUM", + "tags": ["navigation", "query", "search", "pagination"] + }, + { + "id": "conditional-navigation", + "name": "Login with Conditional Navigation", + "description": "Generate a login form that navigates on success with replace history", + "userPrompt": "Create a LoginForm with email/password inputs. On login button click, call /api/login. On success, navigate to /dashboard with replace=true (so user can't go back to login). On error, show error message. Include Sign Up link to /signup and Forgot Password link to /forgot-password with email as query param", + "expectedDslFile": "expect/19-conditional-navigation.nanodsl", + "category": "NAVIGATION", + "difficulty": "MEDIUM", + "tags": ["navigation", "replace", "conditional", "http"] } ], "tags": ["nanodsl", "ai-eval", "ui-generation"]