diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Menu.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Menu.swift new file mode 100644 index 0000000..e9332d5 --- /dev/null +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Menu.swift @@ -0,0 +1,48 @@ +// +// Menu.swift +// AndroidSwiftUICore +// +// A button that presents a dropdown of actions. The content's buttons become +// the menu items; the interpreter shows them in a Compose DropdownMenu. +// + +public struct Menu: View { + + internal let label: Label + internal let content: Content + + public init(@ViewBuilder content: () -> Content, @ViewBuilder label: () -> Label) { + self.content = content() + self.label = label() + } + + public typealias Body = Never +} + +public extension Menu where Label == Text { + init(_ title: S, @ViewBuilder content: () -> Content) { + self.init(content: content) { Text(title) } + } +} + +extension Menu: PrimitiveView { + public func _render(in context: ResolveContext) -> RenderNode { + let labelNodes = Evaluator.resolveChildren(label, context.descending("label")) + let title = labelNodes.lazy.compactMap(firstTextString).first ?? "" + return RenderNode( + type: "Menu", + id: context.path, + props: ["label": .string(title)], + children: Evaluator.resolveChildren(content, context.descending("content")) + ) + } +} + +/// The first Text content found in a node subtree, if any. +internal func firstTextString(_ node: RenderNode) -> String? { + if node.type == "Text", case .string(let text)? = node.props["text"] { return text } + for child in node.children { + if let text = firstTextString(child) { return text } + } + return nil +} diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Stepper.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Stepper.swift new file mode 100644 index 0000000..1ea63d2 --- /dev/null +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Stepper.swift @@ -0,0 +1,76 @@ +// +// Stepper.swift +// AndroidSwiftUICore +// +// A control that increments or decrements a value. The core registers the two +// edge actions as callbacks; the interpreter renders a label plus − / + buttons. +// + +public struct Stepper: View { + + internal let label: Label + internal let onIncrement: () -> Void + internal let onDecrement: () -> Void + + public init( + onIncrement: @escaping () -> Void, + onDecrement: @escaping () -> Void, + @ViewBuilder label: () -> Label + ) { + self.label = label() + self.onIncrement = onIncrement + self.onDecrement = onDecrement + } + + public typealias Body = Never +} + +public extension Stepper where Label == Text { + + init( + _ title: S, + value: Binding, + in bounds: ClosedRange, + step: V.Stride = 1 + ) { + self.init( + onIncrement: { + let next = value.wrappedValue.advanced(by: step) + if next <= bounds.upperBound { value.wrappedValue = next } + }, + onDecrement: { + let previous = value.wrappedValue.advanced(by: -step) + if previous >= bounds.lowerBound { value.wrappedValue = previous } + }, + label: { Text(title) } + ) + } + + init( + _ title: S, + value: Binding, + step: V.Stride = 1 + ) { + self.init( + onIncrement: { value.wrappedValue = value.wrappedValue.advanced(by: step) }, + onDecrement: { value.wrappedValue = value.wrappedValue.advanced(by: -step) }, + label: { Text(title) } + ) + } +} + +extension Stepper: PrimitiveView { + public func _render(in context: ResolveContext) -> RenderNode { + let incrementID = context.callbacks.register(.void(onIncrement)) + let decrementID = context.callbacks.register(.void(onDecrement)) + return RenderNode( + type: "Stepper", + id: context.path, + props: [ + "onIncrement": .int(Int(incrementID)), + "onDecrement": .int(Int(decrementID)), + ], + children: Evaluator.resolveChildren(label, context.descending("label")) + ) + } +} diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Views.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Views.swift index eed8f7c..174b6ee 100644 --- a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Views.swift +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Views.swift @@ -162,6 +162,38 @@ extension TextField: PrimitiveView { } } +/// A text field that obscures its contents. Shares the TextField node with a +/// `secure` flag; the interpreter applies a password transformation. +public struct SecureField: View { + + internal let placeholder: String + internal let text: Binding + + public init(_ placeholder: S, text: Binding) { + self.placeholder = String(placeholder) + self.text = text + } + + public typealias Body = Never +} + +extension SecureField: PrimitiveView { + public func _render(in context: ResolveContext) -> RenderNode { + let binding = text + let callbackID = context.callbacks.register(.string { binding.wrappedValue = $0 }) + return RenderNode( + type: "TextField", + id: context.path, + props: [ + "text": .string(text.wrappedValue), + "placeholder": .string(placeholder), + "onChange": .int(Int(callbackID)), + "secure": .bool(true), + ] + ) + } +} + /// Eager stand-ins: laziness is an optimization the lazy container path (R7) /// provides; semantics match the eager stacks. public typealias LazyVStack = VStack diff --git a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift index 67a0798..5e4d920 100644 --- a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift +++ b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift @@ -76,6 +76,45 @@ struct ControlTests { #expect(node.props["selection"] == .string("Banana")) } + @Test("Stepper increments and decrements within bounds, updating its label") + func stepper() { + struct Screen: View { + @State var count = 5 + var body: some View { Stepper("Count: \(count)", value: $count, in: 0...10) } + } + let host = ViewHost(Screen()) + var node = host.evaluate() + #expect(node.type == "Stepper") + if case .int(let inc)? = node.props["onIncrement"] { host.callbacks.invokeVoid(Int64(inc)) } + node = host.evaluate() + #expect(firstTextString(node) == "Count: 6") + if case .int(let dec)? = node.props["onDecrement"] { host.callbacks.invokeVoid(Int64(dec)) } + node = host.evaluate() + #expect(firstTextString(node) == "Count: 5") + } + + @Test("SecureField emits a secure TextField node") + func secureField() { + struct Screen: View { + @State var password = "" + var body: some View { SecureField("Password", text: $password) } + } + let node = ViewHost(Screen()).evaluate() + #expect(node.type == "TextField") + #expect(node.props["secure"] == .bool(true)) + } + + @Test("Menu emits its label and item children") + func menu() { + let node = ViewHost(Menu("Options") { + Button("First") {} + Button("Second") {} + }).evaluate() + #expect(node.type == "Menu") + #expect(node.props["label"] == .string("Options")) + #expect(node.children.count == 2) + } + @Test("Environment objects reach @Environment properties in the subtree") func environmentInjection() { final class Model { var value = 42 } diff --git a/Demo/App.swiftpm/Sources/Catalog.swift b/Demo/App.swiftpm/Sources/Catalog.swift index f072ac2..81efe91 100644 --- a/Demo/App.swiftpm/Sources/Catalog.swift +++ b/Demo/App.swiftpm/Sources/Catalog.swift @@ -31,6 +31,7 @@ struct CatalogEntry: Identifiable { CatalogEntry(id: "textfield", title: "TextField", screen: AnyCatalogScreen(TextFieldPlayground())), CatalogEntry(id: "picker", title: "Picker", screen: AnyCatalogScreen(PickerPlayground())), CatalogEntry(id: "progress", title: "ProgressView", screen: AnyCatalogScreen(ProgressViewPlayground())), + CatalogEntry(id: "morecontrols", title: "More Controls", screen: AnyCatalogScreen(MoreControlsPlayground())), CatalogEntry(id: "stack", title: "Stacks", screen: AnyCatalogScreen(StackPlayground())), CatalogEntry(id: "spacer", title: "Spacer & Divider", screen: AnyCatalogScreen(SpacerDividerPlayground())), CatalogEntry(id: "color", title: "Color", screen: AnyCatalogScreen(ColorPlayground())), diff --git a/Demo/App.swiftpm/Sources/MoreControlsPlaygrounds.swift b/Demo/App.swiftpm/Sources/MoreControlsPlaygrounds.swift new file mode 100644 index 0000000..6f1f4ce --- /dev/null +++ b/Demo/App.swiftpm/Sources/MoreControlsPlaygrounds.swift @@ -0,0 +1,36 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +struct MoreControlsPlayground: View { + @State private var quantity = 1 + @State private var password = "" + @State private var choice = "None" + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Example("Stepper") { + Stepper("Quantity: \(quantity)", value: $quantity, in: 0...10) + } + Example("SecureField") { + VStack(alignment: .leading, spacing: 8) { + SecureField("Password", text: $password) + Text(password.isEmpty ? "Nothing typed yet" : "\(password.count) characters") + } + } + Example("Menu") { + VStack(alignment: .leading, spacing: 8) { + Menu("Actions") { + Button("Rename") { choice = "Rename" } + Button("Duplicate") { choice = "Duplicate" } + Button("Delete") { choice = "Delete" } + } + Text("Chose: \(choice)") + } + } + } + } + } +} diff --git a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt index 4406067..7f3be00 100644 --- a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt +++ b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt @@ -100,7 +100,9 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.IntOffset @@ -223,6 +225,10 @@ fun Render(node: ViewNode) { "TextField" -> RenderTextField(node) + "Stepper" -> RenderStepper(node) + + "Menu" -> RenderMenu(node) + "Picker" -> RenderPicker(node) "NavStack" -> RenderNavStack(node) @@ -289,10 +295,47 @@ private fun RenderTextField(node: ViewNode) { } }, label = { Text(node.string("placeholder") ?: "") }, + visualTransformation = if (node.bool("secure") == true) PasswordVisualTransformation() else VisualTransformation.None, modifier = node.composeModifiers().fillMaxWidth(), ) } +// Label, then − / + edge buttons pushed to the trailing side. +@Composable +private fun RenderStepper(node: ViewNode) { + val onIncrement = node.long("onIncrement") + val onDecrement = node.long("onDecrement") + Row(verticalAlignment = Alignment.CenterVertically, modifier = node.composeModifiers().fillMaxWidth()) { + RenderChildren(node) + Spacer(modifier = Modifier.weight(1f)) + TextButton(onClick = { onDecrement?.let { SwiftBridge.sink.invokeVoid(it) } }) { Text("−", fontSize = 20.sp) } + TextButton(onClick = { onIncrement?.let { SwiftBridge.sink.invokeVoid(it) } }) { Text("+", fontSize = 20.sp) } + } +} + +// A trigger button opening a dropdown; each child Button becomes a menu item +// firing its own tap callback. +@Composable +private fun RenderMenu(node: ViewNode) { + var expanded by remember { mutableStateOf(false) } + Box { + TextButton(onClick = { expanded = true }) { Text(node.string("label") ?: "") } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + for (child in node.children) { + if (child.isPresentation()) continue + val onTap = child.long("onTap") + DropdownMenuItem( + text = { Text(pickerLabel(child)) }, + onClick = { + expanded = false + onTap?.let { SwiftBridge.sink.invokeVoid(it) } + }, + ) + } + } + } +} + @Composable private fun RenderPicker(node: ViewNode) { val onChange = node.long("onChange")