Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*.sh text eol=lf
*.ada text eol=lf
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
.swiftpm/
dist/
**/.ada/workspace/
__pycache__/
6 changes: 6 additions & 0 deletions Examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,9 @@ Examples are small, focused AdaEngine projects that demonstrate one feature or A
Each example must be independently downloadable and openable in AdaEditor.

Complete games and larger reference projects belong in `Samples`.

## Technical examples

[Technical examples](Technical/README.md) are developer studies and validation
projects, available by cloning this repository. They are deliberately absent from
`catalog.json`, Studio recommendations, website cards and catalog release archives.
6 changes: 6 additions & 0 deletions Examples/Technical/A2UIForm/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.build/
.swiftpm/
dist/
*.xcodeproj/
__pycache__/
Package.resolved
199 changes: 199 additions & 0 deletions Examples/Technical/A2UIForm/A2UIFormDemo.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import AdaA2UI
import AdaEngine
@_spi(Internal) import AdaPlatform
import Foundation
import Observation
#if os(macOS)
import AppKit
#endif

@main
struct A2UIFormDemo: App {
var body: some AppScene {
WindowGroup { FormDemoView() }
.window(with: UIWindow.Configuration(title: "AdaUI · A2UI Forms", frame: Rect(x: 160, y: 140, width: 760, height: 820)))
}
}

@MainActor @Observable
private final class FormDemoModel {
let client = A2UIClient()
var status = "Waiting for the agent stream…"
var lastAction = "Submit the form to inspect its action payload."
var started = false
var lastEvent: A2UIClientEvent?

init() {
client.onEvent = { [weak self] event in
guard let self else { return }
if case let .object(envelope) = event.envelope, envelope["action"] != nil {
self.lastEvent = event
self.lastAction = (try? String(decoding: JSONEncoder().encode(event.envelope), as: UTF8.self)) ?? "Action received"
demoLog("A2UI action: \(self.lastAction)")
} else {
self.status = self.client.lastError?.message ?? "Invalid message rejected"
}
}
}

func start() async {
guard !started else { return }
started = true
do {
guard let url = Bundle.module.url(forResource: "form", withExtension: "jsonl", subdirectory: "Fixtures") else {
throw A2UIValidationError(message: "Demo fixture missing.")
}
let data = try Data(contentsOf: url)
var decoder = A2UIJSONLDecoder()
// Byte chunks exercise the same framing path a network adapter would use.
for offset in stride(from: 0, to: data.count, by: 53) {
let chunk = data.subdata(in: offset..<min(offset + 53, data.count))
for frame in decoder.append(chunk) { try client.receive(frame.get()) }
try await Task.sleep(for: .milliseconds(30))
}
if let frame = decoder.finish() { try client.receive(frame) }
status = "Agent form ready. Edits stay local until submission."
demoLog("A2UI demo stream ready")
if ProcessInfo.processInfo.arguments.contains("--verify") {
try await verify()
}
} catch {
status = error.localizedDescription
demoLog("A2UI demo verification FAIL: \(error.localizedDescription)")
}
}

private func verify() async throws {
try await Task.sleep(for: .milliseconds(150))
guard let window = Application.shared.windowManager.activeWindow ?? Application.shared.windowManager.windows.values.first?.value,
let container = window.uiInspectableContainers().first,
let view = container as? UIView
else { throw A2UIValidationError(message: "Mounted UI container missing.") }
let input = UINodeSelector.accessibilityIdentifier("a2ui.form.name")
_ = try container.uiTapNode(matching: input)
view.onKeyEvent(KeyEvent(window: window.id, keyCode: .a, modifiers: [.control], status: .down, time: 0, isRepeated: false))
view.onTextInputEvent(TextInputEvent(window: window.id, text: "Local NPC", action: .insert, time: 0))
try await Task.sleep(for: .milliseconds(100))
let before = try container.uiLayoutDiagnostics(matching: input, subtreeDepth: 1).focusedNode?.runtimeId
view.onKeyEvent(KeyEvent(window: window.id, keyCode: .a, modifiers: [.control], status: .down, time: 0, isRepeated: false))
updateHeading()
try await Task.sleep(for: .milliseconds(100))
let after = try container.uiLayoutDiagnostics(matching: input, subtreeDepth: 1).focusedNode?.runtimeId
guard before != nil, before == after else { throw A2UIValidationError(message: "Focus was lost during streaming.") }
view.onTextInputEvent(TextInputEvent(window: window.id, text: "Verified NPC", action: .insert, time: 0))
try await Task.sleep(for: .milliseconds(100))
_ = try container.uiTapNode(matching: .accessibilityIdentifier("a2ui.form.enabled"))
try await Task.sleep(for: .milliseconds(100))
_ = try container.uiTapNode(matching: .accessibilityIdentifier("a2ui.form.role.option.merchant"))
try await Task.sleep(for: .milliseconds(100))
_ = try container.uiTapNode(matching: .accessibilityIdentifier("a2ui.form.health"))
try await Task.sleep(for: .milliseconds(100))
_ = try container.uiTapNode(matching: .accessibilityIdentifier("a2ui.form.submit"))
guard case let .object(envelope) = lastEvent?.envelope,
case let .object(action) = envelope["action"],
action["context"] == .object(["name": .string("Verified NPC"), "dialogEnabled": .bool(false), "role": .string("merchant"), "health": .number(51)])
else { throw A2UIValidationError(message: "Input, selection, toggle, or action payload verification failed.") }
guard let surface = client.surfaces["form"] else { throw A2UIValidationError(message: "Surface missing.") }
// Use native keyboard deletion to make the required field invalid.
lastEvent = nil
_ = try container.uiTapNode(matching: input)
view.onKeyEvent(KeyEvent(window: window.id, keyCode: .a, modifiers: [.control], status: .down, time: 0, isRepeated: false))
view.onTextInputEvent(TextInputEvent(window: window.id, text: "", action: .deleteBackward, time: 1))
try await Task.sleep(for: .milliseconds(100))
_ = try container.uiTapNode(matching: .accessibilityIdentifier("a2ui.form.submit"))
guard lastEvent == nil else { throw A2UIValidationError(message: "Invalid form submitted.") }
_ = try container.uiTapNode(matching: input)
view.onTextInputEvent(TextInputEvent(window: window.id, text: "Verified NPC", action: .insert, time: 0))
try await Task.sleep(for: .milliseconds(100))
_ = try container.uiTapNode(matching: .accessibilityIdentifier("a2ui.form.submit"))
guard lastEvent != nil else { throw A2UIValidationError(message: "Valid form stayed disabled.") }
let previous = surface.scene.document
rejectMalformed()
guard surface.scene.document == previous else { throw A2UIValidationError(message: "Malformed update replaced the UI.") }
let arguments = ProcessInfo.processInfo.arguments
if let index = arguments.firstIndex(of: "--proof-directory"), arguments.indices.contains(index + 1) {
let directory = URL(fileURLWithPath: arguments[index + 1])
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try JSONEncoder().encode(container.uiTreeRoots()).write(to: directory.appendingPathComponent("ui-tree.json"))
try surface.snapshot().encodedYAML().write(to: directory.appendingPathComponent("NPCProfile.ui"), atomically: true, encoding: .utf8)
if let lastEvent { try JSONEncoder().encode(lastEvent.envelope).write(to: directory.appendingPathComponent("action.json")) }
}
status = "Verified: streaming, focus, selection, toggle, picker, slider, validation, action, recovery, and .ui export."
demoLog("A2UI demo verification PASS")
}

func updateHeading() {
do {
try client.receive(json: #"{"version":"v0.9.1","updateComponents":{"surfaceId":"form","components":[{"id":"heading","component":"Text","text":"Profile updated by the agent"}]}}"#)
status = "Heading updated; your form values were retained."
} catch { status = error.localizedDescription }
}

func rejectMalformed() {
do {
try client.receive(json: #"{"version":"v0.9.1","updateComponents":{"surfaceId":"form","components":[{"id":"heading","component":"InventedControl"}]}}"#)
} catch { status = "Rejected: \(error.localizedDescription)" }
}

func save() {
#if os(macOS)
let panel = NSSavePanel()
panel.nameFieldStringValue = "NPCProfile.ui"
guard panel.runModal() == .OK, let url = panel.url else { return }
do {
guard let surface = client.surfaces["form"] else { return }
try surface.snapshot().encodedYAML().write(to: url, atomically: true, encoding: .utf8)
status = "Saved \(url.lastPathComponent). Open it in Ada Studio’s UI designer."
} catch { status = error.localizedDescription }
#endif
}
}

private struct FormDemoView: View {
@State private var model = FormDemoModel()
var body: some View {
VStack(alignment: .leading, spacing: 20) {
Text("AdaUI + A2UI").fontSize(26)
Text(model.status).fontSize(14)
A2UISurfaceView(client: model.client, surfaceID: "form")
.frame(width: 560, height: 450, alignment: .topLeading)
HStack(spacing: 12) {
Button("Stream update") { model.updateHeading() }
Button("Malformed update") { model.rejectMalformed() }
#if os(macOS)
Button("Save .ui…") { model.save() }
#endif
}
Text(model.lastAction).fontSize(12).lineLimit(4)
}
.padding(28)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Color(red: 0.09, green: 0.11, blue: 0.15))
.foregroundColor(.white)
.buttonStyle(FormDemoButtonStyle())
.textFieldStyle(FormDemoTextFieldStyle())
.task { await model.start() }
}
}

private func demoLog(_ message: String) {
FileHandle.standardOutput.write(Data((message + "\n").utf8))
}

private struct FormDemoTextFieldStyle: TextFieldStyle {
func _body(configuration: TextField) -> some View {
PlainTextFieldStyle()._body(configuration: configuration)
.padding(.horizontal, 8)
.background(Color(red: 0.16, green: 0.19, blue: 0.24))
.border(Color(red: 0.34, green: 0.39, blue: 0.47), lineWidth: 1)
}
}

private struct FormDemoButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.fontSize(14)
.padding(9)
.background(configuration.isPressed ? Color(red: 0.25, green: 0.38, blue: 0.55) : Color(red: 0.18, green: 0.26, blue: 0.37))
}
}
5 changes: 5 additions & 0 deletions Examples/Technical/A2UIForm/Fixtures/form.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{"version":"v0.9.1","createSurface":{"surfaceId":"form","catalogId":"https://adaengine.org/a2ui/catalogs/forms/v1","sendDataModel":true}}
{"version":"v0.9.1","updateComponents":{"surfaceId":"form","components":[{"id":"root","component":"Column","spacing":16,"children":["heading","name","copy","enabled","role","health-label","health","submit"]},{"id":"heading","component":"Text","text":"Create an NPC profile"}]}}
{"version":"v0.9.1","updateComponents":{"surfaceId":"form","components":[{"id":"name","component":"TextField","placeholder":"NPC name","text":{"path":"/npc/name"},"checks":[{"condition":{"call":"required","args":{"value":{"path":"/npc/name"}}},"message":"NPC name is required."}]},{"id":"copy","component":"Text","text":{"path":"/npc/name"}},{"id":"enabled","component":"Toggle","label":"Enable dialog","value":{"path":"/npc/dialogEnabled"}}]}}
{"version":"v0.9.1","updateDataModel":{"surfaceId":"form","value":{"npc":{"name":"Guide","dialogEnabled":true,"role":"guide","health":50}}}}
{"version":"v0.9.1","updateComponents":{"surfaceId":"form","components":[{"id":"submit","component":"Button","text":"Create profile","action":{"event":{"name":"create_npc","context":{"name":{"path":"/npc/name"},"dialogEnabled":{"path":"/npc/dialogEnabled"},"role":{"path":"/npc/role"},"health":{"path":"/npc/health"}}}},"checks":[{"condition":{"call":"required","args":{"value":{"path":"/npc/name"}}},"message":"NPC name is required."},{"condition":{"call":"range","args":{"value":{"path":"/npc/health"},"min":1,"max":100}},"message":"Health must be between 1 and 100."}]},{"id":"role","component":"ChoicePicker","label":"Role","value":{"path":"/npc/role"},"options":[{"label":"Guide","value":"guide"},{"label":"Merchant","value":"merchant"},{"label":"Guard","value":"guard"}]},{"id":"health-label","component":"Text","text":"Health"},{"id":"health","component":"Slider","value":{"path":"/npc/health"},"min":1,"max":100,"step":1,"checks":[{"condition":{"call":"range","args":{"value":{"path":"/npc/health"},"min":1,"max":100}},"message":"Health must be between 1 and 100."}]}]}}
29 changes: 29 additions & 0 deletions Examples/Technical/A2UIForm/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// swift-tools-version: 6.2
import Foundation
import PackageDescription

let package = Package(
name: "A2UIFormDemo",
platforms: [.macOS(.v15), .iOS(.v18)],
products: [.executable(name: "A2UIFormDemo", targets: ["A2UIFormDemo"])],
dependencies: [
.package(url: "https://github.com/AdaEngine/AdaEngine.git", revision: "a624ccded0a050fddc24ccfbb8079dfb8032805c")
],
targets: [
.executableTarget(
name: "A2UIFormDemo",
dependencies: [
.product(name: "AdaA2UI", package: "adaengine"),
.product(name: "AdaEngine", package: "adaengine"),
.product(name: "AdaPlatform", package: "adaengine")
],
path: ".",
exclude: ["README.md", "script"] + (FileManager.default.fileExists(atPath: "dist") ? ["dist"] : []),
sources: [
"A2UIFormDemo.swift"
],
resources: [.copy("Fixtures")],
swiftSettings: [.enableUpcomingFeature("MemberImportVisibility"), .strictMemorySafety()]
)
]
)
10 changes: 10 additions & 0 deletions Examples/Technical/A2UIForm/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# A2UI form demo

See [the integration guide](https://github.com/AdaEngine/Ada/blob/main/Documentation/A2UI.md).

The NPC form includes a single-choice role picker and stepped health slider.
Required/range checks show inline messages and block invalid submission. Native
verification clears the name, checks rejection, corrects it, chooses a role,
changes health, submits, and exports controls plus validation into `.ui`.

From the AdaExamples root on macOS, run `Examples/Technical/A2UIForm/script/build_and_run.sh --verify`.
69 changes: 69 additions & 0 deletions Examples/Technical/A2UIForm/script/build_and_run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
set -euo pipefail
MODE="${1:-run}"
DEMO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REPO_ROOT="$DEMO_ROOT"
BUILD_PATH="${ADAENGINE_A2UI_BUILD_PATH:-/private/tmp/adaengine-a2ui-build}"
mkdir -p "$BUILD_PATH"
BUILD_PATH="$(cd "$BUILD_PATH" && pwd -P)"
APP="$DEMO_ROOT/dist/A2UIFormDemo.app"
export ADAENGINE_DISABLE_SWAN=1
export CLANG_MODULE_CACHE_PATH="$BUILD_PATH/clang-cache"
export SWIFTPM_MODULECACHE_OVERRIDE="$BUILD_PATH/swift-cache"
case "$MODE" in run|--verify|--debug|--logs|--telemetry) ;; *) echo "usage: $0 [run|--verify|--debug|--logs|--telemetry]" >&2; exit 2 ;; esac
for demo_pid in $(pgrep -f "$APP/Contents/MacOS/A2UIFormDemo" || true); do
demo_command="$(ps -p "$demo_pid" -o command= || true)"
if [[ "$demo_command" == "$APP/Contents/MacOS/A2UIFormDemo" || "$demo_command" == "$APP/Contents/MacOS/A2UIFormDemo "* ]]; then
kill "$demo_pid"
fi
done
mkdir -p "$DEMO_ROOT/dist"
if ! swift build --package-path "$REPO_ROOT" --scratch-path "$BUILD_PATH" --disable-sandbox --product A2UIFormDemo > "$DEMO_ROOT/dist/build.log" 2>&1; then
tail -60 "$DEMO_ROOT/dist/build.log"
exit 1
fi
BIN="$(swift build --package-path "$REPO_ROOT" --scratch-path "$BUILD_PATH" --disable-sandbox --show-bin-path)"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
cp "$BIN/A2UIFormDemo" "$APP/Contents/MacOS/"
for resource in "$BIN"/*.bundle; do
[[ -d "$resource" ]] && cp -R "$resource" "$APP/"
done
cat > "$APP/Contents/Info.plist" <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>CFBundleExecutable</key><string>A2UIFormDemo</string>
<key>CFBundleIdentifier</key><string>org.adaengine.demo.a2ui-form</string>
<key>CFBundleName</key><string>AdaUI A2UI Forms</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>LSMinimumSystemVersion</key><string>15.0</string>
<key>NSPrincipalClass</key><string>NSApplication</string>
</dict></plist>
PLIST
: > "$DEMO_ROOT/dist/runtime.log"
if [[ "$MODE" == --debug ]]; then
/usr/bin/lldb -- "$APP/Contents/MacOS/A2UIFormDemo"
exit
fi
OPEN_ARGS=(-n "$APP" --stdout "$DEMO_ROOT/dist/runtime.log" --stderr "$DEMO_ROOT/dist/runtime.log" --args)
if [[ "$MODE" == --verify ]]; then OPEN_ARGS+=(--verify --proof-directory "$DEMO_ROOT/dist/proof"); fi
/usr/bin/open "${OPEN_ARGS[@]}"
case "$MODE" in
--verify)
for attempt in {1..30}; do
if rg -q 'A2UI demo verification PASS' "$DEMO_ROOT/dist/runtime.log"; then
cat "$DEMO_ROOT/dist/runtime.log"
exit 0
fi
if rg -q 'verification FAIL|Fatal error' "$DEMO_ROOT/dist/runtime.log"; then
tail -40 "$DEMO_ROOT/dist/runtime.log"
exit 1
fi
sleep 1
done
tail -40 "$DEMO_ROOT/dist/runtime.log"
echo 'A2UI verification timed out' >&2
exit 1
;;
--logs|--telemetry) /usr/bin/log stream --info --style compact --predicate 'process == "A2UIFormDemo"' ;;
esac
6 changes: 6 additions & 0 deletions Examples/Technical/AndroidDemo/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.build/
.swiftpm/
dist/
*.xcodeproj/
__pycache__/
Package.resolved
39 changes: 39 additions & 0 deletions Examples/Technical/AndroidDemo/AndroidDemo.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#if os(Android)
import AdaEngine
import AdaPlatform
import Foundation

@_cdecl("ada_android_start")
public func androidDemoStart() {
AndroidRuntime.start { AndroidDemo() }
}

struct AndroidDemo: App {
var body: some AppScene {
WindowGroup(
content: { AndroidDemoView() },
assetBundle: AndroidResourceBundle.bundle(named: "AndroidDemo_AndroidDemo")
)
}
}

private struct AndroidDemoView: View {
@State private var taps = 0
var body: some View {
VStack(spacing: 24) {
Text("AdaEngine on Android").foregroundColor(.white)
Text("Native Swift • Android Surface • Touch").foregroundColor(.white)
Text("Taps: \(taps)").foregroundColor(.white)
Button(action: {
taps += 1
AndroidRuntime.log("AndroidDemo taps=\(taps)")
}) {
Text("Tap +1").foregroundColor(.white)
}
}
}
}

#else
public enum AndroidDemoUnsupportedHost {}
#endif
1 change: 1 addition & 0 deletions Examples/Technical/AndroidDemo/Assets/fixture.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
AdaEngine Android packaged resource
Loading
Loading