diff --git a/.gitignore b/.gitignore index 31f93e7..57fecd3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ packages/ obj/ artifacts/ tools/deps/ +android/app/src/main/assets/current-surface.json +flutter/eve_parity/assets/current-surface.json +flutter/eve_parity/assets/fonts/ *.deb *.ipa *.dSYM/ diff --git a/Sources/EVEViewController.mm b/Sources/EVEViewController.mm index c8992d4..a48cf60 100644 --- a/Sources/EVEViewController.mm +++ b/Sources/EVEViewController.mm @@ -169,6 +169,7 @@ static NSData *EVEMediaObservation(NSString *observationId, @property(nonatomic, copy) NSString *dashboardStatus; @property(nonatomic, copy) NSString *dashboardProviderId; @property(nonatomic, copy) NSString *selectedNodeId; +@property(nonatomic, strong) NSDictionary *paritySurfaceValues; @property(nonatomic, assign) CGFloat activeGestureStartScale; @property(nonatomic, assign) CGFloat activeGestureStartRotation; @property(nonatomic, assign) CGFloat activeSurfaceFontScale; @@ -340,6 +341,7 @@ static NSData *EVEMediaObservation(NSString *observationId, [fileManager createDirectoryAtPath:directory withIntermediateDirectories:YES attributes:nil error:nil]; [fileManager removeItemAtPath:requestPath error:nil]; + [self renderParityFixtureIfPresentInDirectory:directory]; CGSize size = self.view.bounds.size; if (size.width <= 0.0 || size.height <= 0.0) { @@ -392,6 +394,31 @@ static NSData *EVEMediaObservation(NSString *observationId, } } +- (void)renderParityFixtureIfPresentInDirectory:(NSString *)directory { + NSString *fixturePath = [directory stringByAppendingPathComponent:@"current-surface.json"]; + NSData *data = [NSData dataWithContentsOfFile:fixturePath]; + if (!data) { + return; + } + + NSError *error = nil; + NSDictionary *fixture = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; + if (![fixture isKindOfClass:NSDictionary.class]) { + return; + } + + self.paritySurfaceValues = [fixture[@"values"] isKindOfClass:NSDictionary.class] ? fixture[@"values"] : @{}; + self.voidBotDashboardView.hidden = YES; + self.sceneCanvasView.hidden = YES; + self.hierarchyStackView.hidden = YES; + self.toolbarStackView.hidden = YES; + self.dashboardStatusLabel.hidden = YES; + self.surfaceDashboardView.hidden = NO; + [self renderFullscreenSurfaceState:fixture title:fixture[@"title"] version:fixture[@"version"]]; + [self.view setNeedsLayout]; + [self.view layoutIfNeeded]; +} + - (void)updateOverlay { if (self.dashboardView) { self.overlayLabel.hidden = YES; @@ -1145,15 +1172,88 @@ static NSData *EVEMediaObservation(NSString *observationId, NSDictionary *props = [element[@"props"] isKindOfClass:NSDictionary.class] ? element[@"props"] : @{}; NSArray *children = [element[@"children"] isKindOfClass:NSArray.class] ? element[@"children"] : @[]; - if ([kind isEqualToString:@"text"] || [kind isEqualToString:@"metric"]) { + if ([kind isEqualToString:@"label"] || [kind isEqualToString:@"text"] || [kind isEqualToString:@"text.title"] || [kind isEqualToString:@"text.dialogue"] || [kind isEqualToString:@"metric"]) { NSString *text = [self surfaceTextForElement:element props:props fallback:kind]; + if ([kind isEqualToString:@"label"]) { + text = text.uppercaseString; + } return [self surfaceLabelWithText:text - size:depth < 2 ? 12.0 : 10.5 + size:[kind isEqualToString:@"text.title"] ? 18.0 : (depth < 2 ? 12.0 : 10.5) weight:UIFontWeightMedium - color:[self fensalirPrimaryTextColor]]; + color:[kind isEqualToString:@"label"] ? [self fensalirAccentSoftColor] : [self fensalirPrimaryTextColor]]; } - if ([kind isEqualToString:@"rail"]) { + if ([kind isEqualToString:@"control.slider"]) { + NSString *bind = [props[@"bind"] isKindOfClass:NSString.class] ? props[@"bind"] : @""; + NSNumber *value = [self.paritySurfaceValues[bind] isKindOfClass:NSNumber.class] ? self.paritySurfaceValues[bind] : props[@"value"]; + CGFloat min = [props[@"min"] respondsToSelector:@selector(doubleValue)] ? [props[@"min"] doubleValue] : 0.0; + CGFloat max = [props[@"max"] respondsToSelector:@selector(doubleValue)] ? [props[@"max"] doubleValue] : 1.0; + CGFloat raw = [value respondsToSelector:@selector(doubleValue)] ? value.doubleValue : min; + CGFloat percent = max == min ? 0.0 : MIN(MAX((raw - min) / (max - min), 0.0), 1.0); + UIProgressView *slider = [[UIProgressView alloc] initWithProgressViewStyle:UIProgressViewStyleDefault]; + slider.progress = percent; + slider.progressTintColor = [self fensalirAccentSoftColor]; + slider.trackTintColor = [UIColor colorWithWhite:0.0 alpha:0.36]; + [slider.heightAnchor constraintEqualToConstant:18.0].active = YES; + return slider; + } + + if ([kind isEqualToString:@"control.button"]) { + NSString *text = [self surfaceTextForElement:element props:props fallback:@"button"]; + UILabel *button = [self surfaceLabelWithText:text size:11.0 weight:UIFontWeightSemibold color:[self fensalirPrimaryTextColor]]; + button.textAlignment = NSTextAlignmentCenter; + button.layer.borderWidth = 1.0; + button.layer.borderColor = [self fensalirAccentSoftColor].CGColor; + button.layer.cornerRadius = 5.0; + button.backgroundColor = [UIColor colorWithRed:0.34 green:0.15 blue:0.04 alpha:0.32]; + return button; + } + + if ([kind isEqualToString:@"panel.dialogue"]) { + UIView *panel = [[UIView alloc] initWithFrame:CGRectZero]; + panel.backgroundColor = [UIColor colorWithRed:0.014 green:0.050 blue:0.052 alpha:0.94]; + panel.layer.borderWidth = 1.0; + panel.layer.borderColor = [self fensalirOutlineColor].CGColor; + panel.layer.cornerRadius = 7.0; + UIStackView *stack = [[UIStackView alloc] initWithFrame:CGRectZero]; + stack.translatesAutoresizingMaskIntoConstraints = NO; + stack.axis = UILayoutConstraintAxisVertical; + stack.spacing = 5.0; + [panel addSubview:stack]; + [stack addArrangedSubview:[self surfaceHeaderLabelWithText:[props[@"speaker"] isKindOfClass:NSString.class] ? props[@"speaker"] : @"Speaker" size:13.0]]; + [stack addArrangedSubview:[self surfaceLabelWithText:[props[@"text"] isKindOfClass:NSString.class] ? props[@"text"] : @"" size:14.0 weight:UIFontWeightRegular color:[self fensalirPrimaryTextColor]]]; + [NSLayoutConstraint activateConstraints:@[ + [stack.leadingAnchor constraintEqualToAnchor:panel.leadingAnchor constant:10.0], + [stack.trailingAnchor constraintEqualToAnchor:panel.trailingAnchor constant:-10.0], + [stack.topAnchor constraintEqualToAnchor:panel.topAnchor constant:10.0], + [stack.bottomAnchor constraintEqualToAnchor:panel.bottomAnchor constant:-10.0], + ]]; + return panel; + } + + if ([kind isEqualToString:@"embed.tex"]) { + NSString *label = [props[@"label"] isKindOfClass:NSString.class] ? props[@"label"] : @"TeX"; + NSString *source = [props[@"source"] isKindOfClass:NSString.class] ? props[@"source"] : @""; + return [self surfaceLabelWithText:[NSString stringWithFormat:@"%@\n%@", label.uppercaseString, source] size:11.0 weight:UIFontWeightMedium color:[self fensalirPrimaryTextColor]]; + } + + if ([kind isEqualToString:@"embed.norn"]) { + NSDictionary *graph = [props[@"graph"] isKindOfClass:NSDictionary.class] ? props[@"graph"] : @{}; + NSArray *nodes = [graph[@"nodes"] isKindOfClass:NSArray.class] ? graph[@"nodes"] : @[]; + NSMutableArray *labels = [NSMutableArray array]; + for (NSDictionary *node in nodes) { + if (![node isKindOfClass:NSDictionary.class]) continue; + NSString *label = [node[@"label"] isKindOfClass:NSString.class] ? node[@"label"] : node[@"id"]; + if (label.length > 0) [labels addObject:label]; + } + return [self surfaceLabelWithText:[NSString stringWithFormat:@"%@\n%@", [props[@"label"] isKindOfClass:NSString.class] ? props[@"label"] : @"Norn", [labels componentsJoinedByString:@" -> "]] size:11.0 weight:UIFontWeightMedium color:[self fensalirPrimaryTextColor]]; + } + + if ([kind isEqualToString:@"image.background"]) { + return [self surfaceLabelWithText:[props[@"label"] isKindOfClass:NSString.class] ? props[@"label"] : @"Scene" size:11.0 weight:UIFontWeightSemibold color:[UIColor colorWithWhite:0.72 alpha:1.0]]; + } + + if ([kind isEqualToString:@"rail"] || [kind isEqualToString:@"rail.actions"]) { return [self renderSurfaceRail:element props:props children:children depth:depth]; } @@ -1172,7 +1272,8 @@ static NSData *EVEMediaObservation(NSString *observationId, stack.translatesAutoresizingMaskIntoConstraints = NO; NSDictionary *layout = [element[@"layout"] isKindOfClass:NSDictionary.class] ? element[@"layout"] : ([props[@"layout"] isKindOfClass:NSDictionary.class] ? props[@"layout"] : @{}); NSString *direction = [layout[@"direction"] isKindOfClass:NSString.class] ? layout[@"direction"] : @""; - BOOL wantsHorizontal = ![kind isEqualToString:@"row"] && [direction isEqualToString:@"horizontal"]; + NSString *split = [props[@"split"] isKindOfClass:NSString.class] ? props[@"split"] : @""; + BOOL wantsHorizontal = ![kind isEqualToString:@"row"] && ([direction isEqualToString:@"horizontal"] || [split isEqualToString:@"x"]); stack.axis = wantsHorizontal ? UILayoutConstraintAxisHorizontal : UILayoutConstraintAxisVertical; stack.spacing = 6.0; stack.alignment = UIStackViewAlignmentFill; @@ -1289,6 +1390,13 @@ static NSData *EVEMediaObservation(NSString *observationId, - (NSString *)surfaceTextForElement:(NSDictionary *)element props:(NSDictionary *)props fallback:(NSString *)fallback { NSString *text = [props[@"text"] isKindOfClass:NSString.class] ? props[@"text"] : nil; + NSString *bind = [props[@"bind"] isKindOfClass:NSString.class] ? props[@"bind"] : nil; + if (bind.length > 0) { + id value = self.paritySurfaceValues[bind]; + if (value) { + text = [value description]; + } + } if (!text) { text = [element[@"text"] isKindOfClass:NSString.class] ? element[@"text"] : nil; } @@ -1313,7 +1421,7 @@ static NSData *EVEMediaObservation(NSString *observationId, title = [props[@"label"] isKindOfClass:NSString.class] ? props[@"label"] : nil; } NSString *kind = [element[@"kind"] isKindOfClass:NSString.class] ? element[@"kind"] : @""; - BOOL structural = [kind isEqualToString:@"row"] || [kind isEqualToString:@"dashboard"] || [kind isEqualToString:@"cockpit"]; + BOOL structural = [kind isEqualToString:@"row"] || [kind isEqualToString:@"dashboard"] || [kind isEqualToString:@"cockpit"] || [kind isEqualToString:@"surface"] || [kind isEqualToString:@"vn.stage"] || [kind hasPrefix:@"layer."]; if (!title && !structural) { title = [element[@"id"] isKindOfClass:NSString.class] ? element[@"id"] : nil; } diff --git a/android/app/src/main/java/org/gamecult/eve/MainActivity.kt b/android/app/src/main/java/org/gamecult/eve/MainActivity.kt index 4f887c0..73ddb19 100644 --- a/android/app/src/main/java/org/gamecult/eve/MainActivity.kt +++ b/android/app/src/main/java/org/gamecult/eve/MainActivity.kt @@ -2,9 +2,13 @@ package org.gamecult.eve import android.Manifest import android.app.Activity +import android.content.Context import android.content.pm.PackageManager +import android.graphics.Canvas import android.graphics.Color import android.graphics.ImageFormat +import android.graphics.Paint +import android.graphics.RectF import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener @@ -26,6 +30,7 @@ import android.view.Gravity import android.view.MotionEvent import android.view.View import android.view.ViewGroup +import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.ProgressBar import android.widget.ScrollView @@ -45,6 +50,7 @@ import java.util.Date import java.util.Locale import java.util.TimeZone import java.util.concurrent.Executors +import org.json.JSONObject class MainActivity : Activity(), SensorEventListener { private val mesh = CultMeshNode() @@ -79,6 +85,10 @@ class MainActivity : Activity(), SensorEventListener { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + if (intent.getBooleanExtra("org.gamecult.eve.PARITY_FIXTURE", false)) { + setContentView(buildParityFixtureUi()) + return + } sensorManager = getSystemService(SENSOR_SERVICE) as? SensorManager setContentView(buildUi()) startSensors() @@ -87,6 +97,229 @@ class MainActivity : Activity(), SensorEventListener { requestMediaPermissionsAndStart() } + private fun buildParityFixtureUi(): ScrollView { + val raw = assets.open("current-surface.json").bufferedReader().use { it.readText() } + val state = JSONObject(raw) + val values = state.optJSONObject("values") ?: JSONObject() + val surface = state.getJSONObject("surface") + val styles = surface.optJSONObject("styles") ?: JSONObject() + val tokens = styles.optJSONObject("tokens") ?: JSONObject() + val rootNode = surface.getJSONObject("root") + val scroll = ScrollView(this).apply { + isFillViewport = true + setBackgroundColor(tokenColor(tokens, "colorBackground", Color.rgb(2, 9, 9))) + } + val root = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(16), dp(16), dp(16), dp(16)) + } + scroll.addView(root, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + root.addView(renderCultUiNode(rootNode, values, tokens)) + return scroll + } + + private fun renderCultUiNode(node: JSONObject, values: JSONObject, tokens: JSONObject): View { + val kind = node.optString("kind", "panel") + val props = node.optJSONObject("props") ?: JSONObject() + val children = node.optJSONArray("children") + return when (kind) { + "vn.stage" -> FrameLayout(this).apply { + setBackgroundColor(tokenColor(tokens, "colorBackground", Color.rgb(5, 8, 13))) + val visibleAxis = minOf(resources.displayMetrics.widthPixels, resources.displayMetrics.heightPixels) + val height = visibleAxis - 48 + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, height.coerceAtLeast(520)) + val dialogue = findChild(children, "panel.dialogue") + val actions = findChild(children, "rail.actions") + forEachChild(children) { child -> + val childKind = child.optString("kind", "") + if (childKind == "panel.dialogue" || childKind == "rail.actions") return@forEachChild + val view = renderCultUiNode(child, values, tokens) + val params = stageParams(child) + addView(view, params) + } + if (dialogue != null) { + val params = FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.BOTTOM) + params.setMargins(24, 24, 24, if (actions == null) 24 else 92) + addView(renderCultUiNode(dialogue, values, tokens), params) + } + if (actions != null) { + val params = FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.BOTTOM) + params.setMargins(24, 0, 24, 24) + addView(renderCultUiNode(actions, values, tokens), params) + } + } + "image.background" -> StageBackgroundView(this, props.optString("label", "Scene"), tokenColor(tokens, "colorAccent", 0xffff8a2a.toInt())).apply { + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + } + "embed.norn" -> NornGraphView(this, props.optJSONObject("graph") ?: JSONObject(), tokens).apply { + background = android.graphics.drawable.GradientDrawable().apply { + setColor(Color.argb(210, 5, 18, 17)) + setStroke(dp(1), tokenColor(tokens, "colorPanelBorder", Color.argb(110, 103, 240, 228))) + cornerRadius = dp(6).toFloat() + } + } + "embed.tex" -> LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(10), dp(10), dp(10), dp(10)) + background = panelBackground(tokens, 6) + val labelText = props.optString("label", "") + if (labelText.isNotBlank()) addView(label(labelText.uppercase(Locale.US), 11f, tokenColor(tokens, "colorAccent", 0xffff8a2a.toInt()), true)) + addView(label(props.optString("source", ""), 17f, tokenColor(tokens, "colorText", 0xfff6f1e2.toInt()), false)) + } + "panel.dialogue" -> LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(18), dp(18), dp(18), dp(18)) + background = panelBackground(tokens, 8) + addView(label(props.optString("speaker", ""), 15f, tokenColor(tokens, "colorAccent", 0xffff8a2a.toInt()), true)) + addView(label(props.optString("text", ""), 20f, tokenColor(tokens, "colorText", 0xfff6f1e2.toInt()), false)) + } + "rail.actions" -> LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + forEachChild(children) { child -> + addView(renderCultUiNode(child, values, tokens), LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + } + } + "layer.embedded-surfaces", "layer.cards" -> FrameLayout(this).apply { + forEachChild(children) { child -> + addView(renderCultUiNode(child, values, tokens), stageParams(child)) + } + } + "control.button" -> label(props.optString("label", ""), 14f, tokenColor(tokens, "colorText", 0xfff6f1e2.toInt()), true).apply { + gravity = Gravity.CENTER + setPadding(dp(14), dp(12), dp(14), dp(12)) + background = android.graphics.drawable.GradientDrawable().apply { + setColor(Color.argb(44, 255, 138, 42)) + setStroke(dp(1), tokenColor(tokens, "colorAccent", 0xffff8a2a.toInt())) + cornerRadius = dp(6).toFloat() + } + } + "surface" -> LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + forEachChild(children) { addView(renderCultUiNode(it, values, tokens)) } + } + "partition" -> LinearLayout(this).apply { + if (props.optString("role", "") == "inspector.row") { + orientation = LinearLayout.HORIZONTAL + setPadding(dp(8), dp(4), dp(8), dp(4)) + background = android.graphics.drawable.GradientDrawable( + android.graphics.drawable.GradientDrawable.Orientation.TOP_BOTTOM, + intArrayOf(Color.rgb(32, 31, 43), Color.rgb(19, 20, 29))).apply { + setStroke(dp(1), Color.argb(16, 255, 255, 255)) + cornerRadius = dp(4).toFloat() + } + val labelNode = children?.optJSONObject(0) + val fieldNode = children?.optJSONObject(1) + if (labelNode != null) { + addView(renderCultUiNode(labelNode, values, tokens), LinearLayout.LayoutParams(dp(190), ViewGroup.LayoutParams.WRAP_CONTENT)) + } + if (fieldNode != null) { + addView(renderCultUiNode(fieldNode, values, tokens), LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + } + return@apply + } + orientation = if (props.optString("split", "y") == "x") LinearLayout.HORIZONTAL else LinearLayout.VERTICAL + setPadding(dp(props.optInt("padding", 0)), dp(props.optInt("padding", 0)), dp(props.optInt("padding", 0)), dp(props.optInt("padding", 0))) + forEachChild(children) { child -> + val view = renderCultUiNode(child, values, tokens) + val params = LinearLayout.LayoutParams( + if (orientation == LinearLayout.HORIZONTAL) 0 else ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + if (orientation == LinearLayout.HORIZONTAL) 1f else 0f) + val gap = dp(props.optInt("gap", 0)) + params.setMargins(0, 0, if (orientation == LinearLayout.HORIZONTAL) gap else 0, if (orientation == LinearLayout.VERTICAL) gap else 0) + addView(view, params) + } + } + "pane", "panel" -> LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(props.optInt("padding", 12)), dp(props.optInt("padding", 12)), dp(props.optInt("padding", 12)), dp(props.optInt("padding", 12))) + setBackgroundColor(tokenColor(tokens, "colorPanel", Color.rgb(7, 25, 24))) + background = android.graphics.drawable.GradientDrawable().apply { + setColor(tokenColor(tokens, "colorPanel", Color.rgb(7, 25, 24))) + setStroke(dp(1), Color.argb(110, 103, 240, 228)) + cornerRadius = dp(6).toFloat() + } + val title = props.optString("title", "") + if (title.isNotBlank()) addView(label(title.uppercase(Locale.US), 12f, tokenColor(tokens, "colorMuted", 0xff8ba5a3.toInt()), true)) + forEachChild(children) { addView(renderCultUiNode(it, values, tokens)) } + } + "card", "card.external" -> LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(10), dp(10), dp(10), dp(10)) + background = android.graphics.drawable.GradientDrawable().apply { + setColor(tokenColor(tokens, "colorPanelAlt", Color.rgb(19, 20, 29))) + setStroke(dp(1), Color.argb(24, 255, 255, 255)) + cornerRadius = dp(4).toFloat() + } + val title = props.optString("title", "") + if (title.isNotBlank()) addView(label(title, 14f, tokenColor(tokens, "colorText", 0xffe7f1f1.toInt()), true)) + forEachChild(children) { addView(renderCultUiNode(it, values, tokens)) } + } + "label", "text", "text.title", "text.dialogue" -> { + val bind = props.optString("bind", "") + val text = if (bind.isNotBlank()) values.opt(bind)?.toString() ?: "" else props.optString("text", "") + label(if (kind == "label") text.uppercase(Locale.US) else text, if (kind == "text.title") 18f else 13f, if (kind == "label") tokenColor(tokens, "colorAccent", 0xffffb84f.toInt()) else tokenColor(tokens, "colorText", 0xffe7f1f1.toInt()), kind == "label") + } + "control.slider" -> CultSliderView(this, sliderValue(props, values), tokenColor(tokens, "colorAccent", 0xffffb84f.toInt())).apply { + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(24)) + } + else -> LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + forEachChild(children) { addView(renderCultUiNode(it, values, tokens)) } + } + } + } + + private fun stageParams(node: JSONObject): FrameLayout.LayoutParams { + val kind = node.optString("kind", "") + val props = node.optJSONObject("props") ?: JSONObject() + val placement = props.optJSONObject("placement") + val anchor = placement?.optString("anchor", "") ?: "" + return when { + kind == "image.background" -> FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + kind == "embed.norn" || anchor == "whiteboard" -> FrameLayout.LayoutParams(520, 310).apply { setMargins(54, 46, 0, 0) } + kind == "embed.tex" || anchor == "whiteboard-equation" -> FrameLayout.LayoutParams(500, 94).apply { setMargins(96, 350, 0, 0) } + kind.startsWith("layer.") -> FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + else -> FrameLayout.LayoutParams(300, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.TOP or Gravity.RIGHT).apply { setMargins(0, 52, 32, 0) } + } + } + + private fun findChild(children: org.json.JSONArray?, kind: String): JSONObject? { + if (children == null) return null + for (index in 0 until children.length()) { + val child = children.getJSONObject(index) + if (child.optString("kind", "") == kind) return child + } + return null + } + + private fun panelBackground(tokens: JSONObject, radius: Int): android.graphics.drawable.GradientDrawable { + return android.graphics.drawable.GradientDrawable().apply { + setColor(tokenColor(tokens, "colorPanel", Color.rgb(7, 25, 24))) + setStroke(dp(1), Color.argb(110, 103, 240, 228)) + cornerRadius = dp(radius).toFloat() + } + } + + private fun forEachChild(children: org.json.JSONArray?, block: (JSONObject) -> Unit) { + if (children == null) return + for (index in 0 until children.length()) block(children.getJSONObject(index)) + } + + private fun sliderValue(props: JSONObject, values: JSONObject): Double { + val min = props.optDouble("min", 0.0) + val max = props.optDouble("max", 1.0) + val bind = props.optString("bind", "") + val value = if (bind.isNotBlank()) values.optDouble(bind, min) else props.optDouble("value", min) + return if (max == min) 0.0 else ((value - min) / (max - min)).coerceIn(0.0, 1.0) + } + + private fun tokenColor(tokens: JSONObject, name: String, fallback: Int): Int { + val value = tokens.optString(name, "") + if (!value.startsWith("#") || value.length != 7) return fallback + return Color.rgb(value.substring(1, 3).toInt(16), value.substring(3, 5).toInt(16), value.substring(5, 7).toInt(16)) + } + override fun onDestroy() { super.onDestroy() mediaRunning = false @@ -622,3 +855,130 @@ class MainActivity : Activity(), SensorEventListener { timeZone = TimeZone.getTimeZone("UTC") }.format(Date()) } + +private class CultSliderView( + context: Context, + private val value: Double, + private val accent: Int, +) : View(context) { + private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.argb(87, 0, 0, 0) } + private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = accent } + private val glowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.argb(115, Color.red(accent), Color.green(accent), Color.blue(accent)) + maskFilter = android.graphics.BlurMaskFilter(7f, android.graphics.BlurMaskFilter.Blur.NORMAL) + } + private val thumbPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = accent } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val desiredHeight = (24f * resources.displayMetrics.density + 0.5f).toInt() + val width = MeasureSpec.getSize(widthMeasureSpec) + val height = resolveSize(desiredHeight, heightMeasureSpec) + setMeasuredDimension(width, height) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val centerY = height / 2f + val left = paddingLeft.toFloat() + val right = (width - paddingRight).toFloat() + val track = RectF(left, centerY - 3f, right, centerY + 3f) + val fillRight = left + ((right - left) * value).toFloat() + val fill = RectF(left, centerY - 3f, fillRight, centerY + 3f) + canvas.drawRoundRect(track, 2f, 2f, trackPaint) + canvas.drawRoundRect(fill, 2f, 2f, fillPaint) + canvas.drawCircle(fillRight, centerY, 10f, glowPaint) + canvas.drawCircle(fillRight, centerY, 6f, thumbPaint) + } +} + +private class StageBackgroundView( + context: Context, + private val sceneLabel: String, + private val accent: Int +) : View(context) { + private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.rgb(5, 8, 13) } + private val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.argb(28, Color.red(accent), Color.green(accent), Color.blue(accent)) + strokeWidth = 1f + } + private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.rgb(183, 199, 217) + textSize = 14f * resources.displayMetrics.scaledDensity + typeface = android.graphics.Typeface.DEFAULT_BOLD + } + + override fun onDraw(canvas: Canvas) { + canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), backgroundPaint) + val step = 32f * resources.displayMetrics.density + var x = 0f + while (x < width) { + canvas.drawLine(x, 0f, x, height.toFloat(), gridPaint) + x += step + } + var y = 0f + while (y < height) { + canvas.drawLine(0f, y, width.toFloat(), y, gridPaint) + y += step + } + canvas.drawText(sceneLabel, 28f * resources.displayMetrics.density, 36f * resources.displayMetrics.density, textPaint) + } +} + +private class NornGraphView( + context: Context, + private val graph: JSONObject, + private val tokens: JSONObject +) : View(context) { + private val density = resources.displayMetrics.density + private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = colorToken("colorText", Color.rgb(246, 241, 226)) + textSize = 13f * resources.displayMetrics.scaledDensity + textAlign = Paint.Align.CENTER + typeface = android.graphics.Typeface.DEFAULT_BOLD + } + private val edgePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.argb(120, 183, 199, 217) + strokeWidth = 2f * density + } + + override fun onDraw(canvas: Canvas) { + val nodes = graph.optJSONArray("nodes") ?: return + val edges = graph.optJSONArray("edges") + val points = mutableMapOf() + for (index in 0 until nodes.length()) { + val node = nodes.getJSONObject(index) + val id = node.optString("id", "") + points[id] = android.graphics.PointF((node.optDouble("x", 0.5) * width).toFloat(), (node.optDouble("y", 0.5) * height).toFloat()) + } + if (edges != null) { + for (index in 0 until edges.length()) { + val edge = edges.getJSONObject(index) + val source = points[edge.optString("source", "")] + val target = points[edge.optString("target", "")] + if (source != null && target != null) canvas.drawLine(source.x, source.y, target.x, target.y, edgePaint) + } + } + val nodePaint = Paint(Paint.ANTI_ALIAS_FLAG) + val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 2f * density + } + for (index in 0 until nodes.length()) { + val node = nodes.getJSONObject(index) + val point = points[node.optString("id", "")] ?: continue + val current = node.optBoolean("current", false) + nodePaint.color = if (current) colorToken("colorAccent", Color.rgb(255, 138, 42)) else Color.rgb(20, 20, 29) + strokePaint.color = if (current) colorToken("colorAccent", Color.rgb(255, 138, 42)) else Color.argb(140, 103, 240, 228) + val radius = (if (current) 24f else 20f) * density + canvas.drawCircle(point.x, point.y, radius, nodePaint) + canvas.drawCircle(point.x, point.y, radius, strokePaint) + canvas.drawText(node.optString("label", node.optString("id", "")), point.x, point.y + radius + 20f * density, textPaint) + } + } + + private fun colorToken(name: String, fallback: Int): Int { + val value = tokens.optString(name, "") + if (!value.startsWith("#") || value.length != 7) return fallback + return Color.rgb(value.substring(1, 3).toInt(16), value.substring(3, 5).toInt(16), value.substring(5, 7).toInt(16)) + } +} diff --git a/docs/cultui-style-system.md b/docs/cultui-style-system.md index 8df7477..f9b5f3c 100644 --- a/docs/cultui-style-system.md +++ b/docs/cultui-style-system.md @@ -375,6 +375,33 @@ Runtime lowerers must report capability gaps. If Android cannot yet render Repixelizer's pixel-art image preview or cleanup canvas, the surface should say that plainly instead of faking a control that cannot carry the command. +### Parity Unit Boundary + +CultUI layout values are portable design units. A runtime may map them through +device scale for normal application rendering, but parity capture must make +that mapping explicit. The screenshot harness treats responsive web sizes, +Flutter test surface sizes, Android `wm size` captures, and native panel +captures as different verification layers. + +Renderer defaults are forbidden writers for portable geometry. A runtime can +choose the local primitive that draws a slider, graph, card, dialogue panel, or +button, but the portable surface owns: + +- component kind; +- child order; +- partition split, size, padding, gap, and alignment; +- style tokens and variants; +- element anatomy such as slider track/fill/thumb/bleed; +- embedded surface identity such as `embed.norn` or `embed.tex`; +- command identity and payload shape. + +The current parity harness exposes this boundary with five fixtures: +CultUI Inspector, Repixelizer, Sai VN, Reactive DSL, and Huginn `.cc`. The +current screenshot body emits web, Windows Flutter, Linux Flutter on Nightwing, +iOS/UIKit, and Android/Kotlin captures. Fensalir Direct2D remains a named +pending target until the Eve-to-`AquariumUiDocument` adapter and Direct2D frame +capture exist. + ## Style Patches Style controls emit `style.patch`. The provider accepts, rejects, or reconciles diff --git a/docs/parity-testing-harness.md b/docs/parity-testing-harness.md index c7c1d13..b4a6c6d 100644 --- a/docs/parity-testing-harness.md +++ b/docs/parity-testing-harness.md @@ -109,11 +109,13 @@ Pending runtimes are allowed. Silent fake parity is not. ## Next Cuts -1. Move the Flutter parity renderer from hardcoded inspector anatomy to the - compiled `gamecult.eve.surface.v1` fixture. -2. Wire iOS and Android capture to select a manifest fixture before capture, - instead of only launching the current app body. -3. Replace Flutter golden screenshots with native window captures once the +1. Add screenshot comparison metrics that score structure, color tokens, + bounding boxes, and text presence without pretending byte-identical pixels + are the goal. +2. Normalize text scale and font loading across web, Flutter, Android, and iOS. +3. Give iOS a real `vn.stage` scene compositor instead of compact stacked + fixture lowering. +4. Replace Flutter golden screenshots with native window captures once the Flutter app body can emit a desktop frame without test harness help. -4. Add Direct2D frame capture once the Eve-to-`AquariumUiDocument` adapter +5. Add Direct2D frame capture once the Eve-to-`AquariumUiDocument` adapter exists. diff --git a/docs/renderer-parity.md b/docs/renderer-parity.md index 389c8f6..16aca9e 100644 --- a/docs/renderer-parity.md +++ b/docs/renderer-parity.md @@ -23,10 +23,10 @@ current evidence surface for semantic parity and runtime capture gaps. | Renderer | Status | Proof | Gap | | --- | --- | --- | --- | | Web reference | Active reference/debug renderer | `web/` renders live VoidBot and fixture surfaces through the provider picker; compiles indentation CultUI; lowers partitions, field rows, and slider anatomy; Chrome headless capture emits phone/tablet/desktop PNGs | Needs command round-trip tests | -| Windows / Flutter | Active shared graphical smoke target | `flutter/eve_parity` renders the CultUI Inspector surface and emits phone/tablet/desktop golden PNGs through `capture-flutter-parity.ps1` | Needs lowerer fed by compiled `gamecult.eve.surface.v1` instead of hardcoded inspector widget | -| Linux / Flutter | Runner-blocked shared graphical target | Flutter Linux platform scaffold exists | Needs a Linux host/runner to emit a real Linux PNG | -| iOS / UIKit | Native proof | Renders Odin's fullscreen interface wall from `surface.root`, generic provider trees, and a custom VoidBot cockpit with avatar images; SSH capture emits PNGs | Needs provider picker, Repixelizer style token lowering, and a decision on what UIKit still owns if Flutter covers the shared graphical path | -| Android / Kotlin | Device-edge proof | APK builds, Periwinkle attaches through adb, and smoke capture emits phone/tablet/desktop PNGs via `adb shell wm size` | Needs provider picker, full surface tree rendering, style token lowering, asset image lowering, command controls, and a decision on what Kotlin still owns if Flutter covers the shared graphical path | +| Windows / Flutter | Active shared graphical smoke target | `flutter/eve_parity` loads exported `gamecult.eve.surface.v1` fixture JSON and emits phone/tablet/desktop golden PNGs through `capture-flutter-parity.ps1` | Needs native window capture and tighter text/style normalization | +| Linux / Flutter | Active shared graphical smoke target | Nightwing runs the Flutter parity goldens over SSH and returns phone/tablet/desktop PNGs | Needs native window capture and runner health checks | +| iOS / UIKit | Active native screenshot target | SSH capture stages selected fixture JSON, EveCanvas renders it, and emits a PNG from `/var/mobile/Library/EveCanvas/latest-screenshot.png` | Needs real scene composition for `vn.stage`, Repixelizer token fidelity, and responsive/simulator capture | +| Android / Kotlin | Active device screenshot target | APK builds with selected fixture assets; Periwinkle captures phone/tablet/desktop plus native portrait/landscape PNGs through adb | Needs text scale/font normalization, image lowering, and command controls | | Fensalir Direct2D | Specialized native/game-runtime landing zone | Existing `AquariumUiDocument` and `DirectWriteOverlay` path | Needs adapter from Eve surface document to `AquariumUiDocument`, DirectWrite/Direct2D token lowering, and a clear reason to stay separate from Flutter for desktop UI | ## Spawned Surfaces diff --git a/flutter/eve_parity/lib/main.dart b/flutter/eve_parity/lib/main.dart index f366ae3..0e82609 100644 --- a/flutter/eve_parity/lib/main.dart +++ b/flutter/eve_parity/lib/main.dart @@ -1,131 +1,54 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; void main() { runApp(const EveParityApp()); } -class EveParityApp extends StatelessWidget { - const EveParityApp({super.key}); +class EveParityApp extends StatefulWidget { + const EveParityApp({super.key, this.assetPath = 'assets/current-surface.json'}); + + final String assetPath; + + @override + State createState() => _EveParityAppState(); +} + +class _EveParityAppState extends State { + late Future surface; + + @override + void initState() { + super.initState(); + surface = EveSurfaceState.load(widget.assetPath); + } @override Widget build(BuildContext context) { - return const MaterialApp( + return MaterialApp( debugShowCheckedModeBanner: false, - home: Scaffold( - backgroundColor: Color(0xFF020909), - body: SafeArea( - child: Padding( - padding: EdgeInsets.all(16), - child: CultUiInspectorSurface(), - ), - ), - ), - ); - } -} - -class CultUiInspectorSurface extends StatelessWidget { - const CultUiInspectorSurface({super.key}); - - @override - Widget build(BuildContext context) { - return Align( - alignment: Alignment.topLeft, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 1180), - child: DecoratedBox( - decoration: BoxDecoration( - color: const Color(0xEE071918), - border: Border.all(color: const Color(0x4767F0E4)), - borderRadius: BorderRadius.circular(6), - boxShadow: const [ - BoxShadow(color: Color(0x59000000), blurRadius: 26, offset: Offset(0, 12)), - ], - ), - child: const Padding( - padding: EdgeInsets.all(12), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'AETHERIC FIELD TESTER', - style: TextStyle( - color: Color(0xFF8BA5A3), - fontFamily: 'monospace', - fontSize: 12, - fontWeight: FontWeight.w700, - ), - ), - SizedBox(height: 10), - InspectorSliderRow(label: 'FOV', value: 38 / 120), - SizedBox(height: 6), - InspectorSliderRow(label: 'THROTTLE DECAY', value: 5 / 20), - SizedBox(height: 6), - InspectorSliderRow(label: 'FLOW SPEED', value: 2 / 10), - ], + home: FutureBuilder( + future: surface, + builder: (context, snapshot) { + final state = snapshot.data; + final tokens = state?.tokens ?? EveTokens.defaults; + return Scaffold( + backgroundColor: tokens.background, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: snapshot.hasError + ? Text( + 'Eve surface load failed\n${snapshot.error}', + style: const TextStyle(color: Color(0xFFFFB84F), fontFamily: 'monospace'), + ) + : state == null + ? const SizedBox.shrink() + : EveSurfaceView(state: state), + ), ), - ), - ), - ), - ); - } -} - -class InspectorSliderRow extends StatelessWidget { - const InspectorSliderRow({ - required this.label, - required this.value, - super.key, - }); - - final String label; - final double value; - - @override - Widget build(BuildContext context) { - return Container( - constraints: const BoxConstraints(minHeight: 34), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0xF5201F2B), Color(0xF513141D)], - ), - border: Border.all(color: const Color(0x10FFFFFF)), - borderRadius: BorderRadius.circular(4), - boxShadow: const [ - BoxShadow(color: Color(0x66000000), blurRadius: 8, offset: Offset(0, 2)), - ], - ), - child: LayoutBuilder( - builder: (context, constraints) { - final labelText = Text( - label, - style: const TextStyle( - color: Color(0xFFFFB84F), - fontFamily: 'monospace', - fontSize: 13, - fontWeight: FontWeight.w700, - ), - ); - if (constraints.maxWidth < 520) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - labelText, - const SizedBox(height: 4), - CultUiSlider(value: value), - ], - ); - } - - return Row( - children: [ - SizedBox(width: 190, child: labelText), - Expanded(child: CultUiSlider(value: value)), - ], ); }, ), @@ -133,53 +56,744 @@ class InspectorSliderRow extends StatelessWidget { } } -class CultUiSlider extends StatelessWidget { - const CultUiSlider({required this.value, super.key}); +class EveSurfaceState { + EveSurfaceState({ + required this.providerId, + required this.title, + required this.root, + required this.tokens, + required this.controlSkins, + required this.values, + }); - final double value; + final String providerId; + final String title; + final EveNode root; + final EveTokens tokens; + final Map> controlSkins; + final Map values; + + static Future load(String assetPath) async { + final raw = await rootBundle.loadString(assetPath); + final json = jsonDecode(raw) as Map; + final surface = json['surface'] as Map; + final styles = (surface['styles'] as Map?) ?? const {}; + final skins = >{}; + final rawSkins = (styles['controlSkins'] as Map?) ?? const {}; + for (final entry in rawSkins.entries) { + final skin = entry.value as Map; + skins[entry.key] = ((skin['children'] as List?) ?? const []) + .map((child) => EveNode.fromJson(child as Map)) + .toList(); + } + return EveSurfaceState( + providerId: (json['providerId'] ?? '').toString(), + title: (json['title'] ?? '').toString(), + root: EveNode.fromJson(surface['root'] as Map), + tokens: EveTokens.fromJson((styles['tokens'] as Map?) ?? const {}), + controlSkins: skins, + values: (json['values'] as Map?) ?? const {}, + ); + } +} + +class EveNode { + EveNode({ + required this.id, + required this.kind, + required this.props, + required this.layout, + required this.children, + }); + + final String id; + final String kind; + final Map props; + final Map layout; + final List children; + + factory EveNode.fromJson(Map json) { + return EveNode( + id: (json['id'] ?? '').toString(), + kind: (json['kind'] ?? 'panel').toString(), + props: (json['props'] as Map?) ?? const {}, + layout: (json['layout'] as Map?) ?? const {}, + children: ((json['children'] as List?) ?? const []) + .map((child) => EveNode.fromJson(child as Map)) + .toList(), + ); + } +} + +class EveTokens { + const EveTokens({ + required this.background, + required this.panel, + required this.panelAlt, + required this.panelInset, + required this.border, + required this.text, + required this.muted, + required this.accent, + }); + + final Color background; + final Color panel; + final Color panelAlt; + final Color panelInset; + final Color border; + final Color text; + final Color muted; + final Color accent; + + static const defaults = EveTokens( + background: Color(0xFF020909), + panel: Color(0xFF071918), + panelAlt: Color(0xFF15151D), + panelInset: Color(0x57000000), + border: Color(0x4767F0E4), + text: Color(0xFFE7F1F1), + muted: Color(0xFF8BA5A3), + accent: Color(0xFFFFB84F), + ); + + factory EveTokens.fromJson(Map json) { + return EveTokens( + background: _color(json['colorBackground'], defaults.background), + panel: _color(json['colorPanel'], defaults.panel), + panelAlt: _color(json['colorPanelAlt'], defaults.panelAlt), + panelInset: _color(json['colorPanelInset'], defaults.panelInset), + border: _color(json['colorPanelBorder'], defaults.border), + text: _color(json['colorText'], defaults.text), + muted: _color(json['colorMuted'], defaults.muted), + accent: _color(json['colorAccent'], defaults.accent), + ); + } + + static Color _color(Object? value, Color fallback) { + if (value is! String) return fallback; + final hex = value.trim(); + if (!hex.startsWith('#')) return fallback; + final body = hex.substring(1); + if (body.length == 6) return Color(int.parse('ff$body', radix: 16)); + if (body.length == 8) return Color(int.parse(body, radix: 16)); + return fallback; + } +} + +class EveSurfaceView extends StatelessWidget { + const EveSurfaceView({required this.state, super.key}); + + final EveSurfaceState state; @override Widget build(BuildContext context) { - return CustomPaint( - painter: _SliderPainter(value.clamp(0, 1)), - child: const SizedBox(height: 22, width: double.infinity), - ); + return EveNodeView(state: state, node: state.root); } } -class _SliderPainter extends CustomPainter { - _SliderPainter(this.value); +class EveNodeView extends StatelessWidget { + const EveNodeView({required this.state, required this.node, super.key}); - final double value; + final EveSurfaceState state; + final EveNode node; + + @override + Widget build(BuildContext context) { + switch (node.kind) { + case 'surface': + return _surface(); + case 'partition': + return _partition(); + case 'pane': + case 'panel': + return _pane(); + case 'card': + case 'card.external': + return _card(); + case 'label': + case 'text': + case 'text.title': + case 'text.dialogue': + return _text(); + case 'control.slider': + return EveSlider(state: state, node: node); + case 'control.button': + return _button(); + case 'vn.stage': + return _vnStage(); + case 'image.background': + return _background(); + case 'embed.norn': + return _norn(); + case 'embed.tex': + return _tex(); + case 'panel.dialogue': + return _dialogue(); + case 'rail.actions': + return _actions(); + default: + return _fallback(); + } + } + + Widget _vnStage() { + final background = node.children.where((child) => child.kind == 'image.background').firstOrNull; + final sceneChildren = node.children.where((child) { + return child.kind != 'image.background' && child.kind != 'panel.dialogue' && child.kind != 'rail.actions'; + }).toList(); + final dialogue = node.children.where((child) => child.kind == 'panel.dialogue').firstOrNull; + final actions = node.children.where((child) => child.kind == 'rail.actions').firstOrNull; + + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final height = constraints.maxHeight.isFinite ? constraints.maxHeight : width * 0.56; + return SizedBox( + width: width, + height: height.clamp(520, 900), + child: Stack( + children: [ + Positioned.fill( + child: background == null + ? DecoratedBox(decoration: BoxDecoration(color: state.tokens.background)) + : EveNodeView(state: state, node: background), + ), + for (final child in sceneChildren) _stageOverlay(child), + if (dialogue != null) + Positioned( + left: 28, + right: 28, + bottom: actions == null ? 24 : 96, + child: EveNodeView(state: state, node: dialogue), + ), + if (actions != null) + Positioned(left: 28, right: 28, bottom: 24, child: EveNodeView(state: state, node: actions)), + ], + ), + ); + }, + ); + } + + Positioned _stageOverlay(EveNode child) { + if (child.kind == 'layer.embedded-surfaces' || child.kind == 'layer.cards') { + return Positioned.fill( + child: IgnorePointer( + child: Stack(children: [for (final grandchild in child.children) _stageOverlay(grandchild)]), + ), + ); + } + final placement = _map(child.props['placement']); + final anchor = placement['anchor']?.toString(); + if (child.kind == 'embed.norn' || anchor == 'whiteboard') { + return Positioned(left: 54, top: 46, width: 520, height: 310, child: EveNodeView(state: state, node: child)); + } + if (child.kind == 'embed.tex' || anchor == 'whiteboard-equation') { + return Positioned(left: 96, top: 350, width: 500, height: 94, child: EveNodeView(state: state, node: child)); + } + return Positioned(right: 32, top: 52, width: 300, child: EveNodeView(state: state, node: child)); + } + + Widget _background() { + return DecoratedBox( + decoration: BoxDecoration( + color: state.tokens.background, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [state.tokens.panelAlt, state.tokens.background, const Color(0xFF10161F)], + ), + ), + child: Stack( + children: [ + Positioned.fill( + child: CustomPaint(painter: EveGridPainter(color: state.tokens.accent.withValues(alpha: 0.10))), + ), + Positioned( + left: 28, + top: 24, + child: Text( + node.props['label']?.toString() ?? 'Scene', + style: TextStyle( + color: state.tokens.muted, + fontFamily: 'EveParity', + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ); + } + + Widget _norn() { + final graph = _map(node.props['graph']); + final nodes = (graph['nodes'] as List?) ?? const []; + final edges = (graph['edges'] as List?) ?? const []; + return DecoratedBox( + decoration: BoxDecoration( + color: state.tokens.panel.withValues(alpha: 0.82), + border: Border.all(color: state.tokens.border), + borderRadius: BorderRadius.circular(6), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: CustomPaint( + painter: EveNornPainter(nodes: nodes, edges: edges, tokens: state.tokens), + child: const SizedBox.expand(), + ), + ), + ); + } + + Widget _tex() { + return DecoratedBox( + decoration: BoxDecoration( + color: state.tokens.panel.withValues(alpha: 0.88), + border: Border.all(color: state.tokens.border), + borderRadius: BorderRadius.circular(5), + ), + child: Padding( + padding: const EdgeInsets.all(10), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (node.props['label'] != null) + Text( + node.props['label'].toString().toUpperCase(), + style: TextStyle( + color: state.tokens.accent, + fontFamily: 'EveParity', + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + Text( + node.props['source']?.toString() ?? '', + style: TextStyle(color: state.tokens.text, fontFamily: 'EveParity', fontSize: 18), + ), + ], + ), + ), + ); + } + + Widget _dialogue() { + final speaker = node.props['speaker']?.toString() ?? ''; + final text = node.props['text']?.toString() ?? ''; + return DecoratedBox( + decoration: BoxDecoration( + color: state.tokens.panel.withValues(alpha: 0.94), + border: Border.all(color: state.tokens.border), + borderRadius: BorderRadius.circular(8), + boxShadow: const [BoxShadow(color: Color(0x8A000000), blurRadius: 24, offset: Offset(0, 10))], + ), + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + speaker, + style: TextStyle( + color: state.tokens.accent, + fontFamily: 'EveParity', + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text(text, style: TextStyle(color: state.tokens.text, fontFamily: 'EveParity', fontSize: 20)), + ], + ), + ), + ); + } + + Widget _actions() { + return Row( + children: [ + for (var index = 0; index < node.children.length; index += 1) ...[ + Expanded(child: EveNodeView(state: state, node: node.children[index])), + if (index != node.children.length - 1) const SizedBox(width: 10), + ], + ], + ); + } + + Widget _button() { + final label = node.props['label']?.toString() ?? node.props['text']?.toString() ?? ''; + return DecoratedBox( + decoration: BoxDecoration( + color: state.tokens.accent.withValues(alpha: 0.17), + border: Border.all(color: state.tokens.accent.withValues(alpha: 0.64)), + borderRadius: BorderRadius.circular(6), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Text( + label, + textAlign: TextAlign.center, + style: TextStyle( + color: state.tokens.text, + fontFamily: 'EveParity', + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } + + Widget _surface() { + final padding = _number(node.layout['padding'] ?? node.props['padding'], 0); + return Padding( + padding: EdgeInsets.all(padding), + child: LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth >= 900 && node.children.length > 1) { + return _children(axis: Axis.horizontal, maxWidth: 1280); + } + return _children(axis: Axis.vertical, maxWidth: 1180); + }, + ), + ); + } + + Widget _partition() { + final split = (node.props['split'] ?? _layoutDirection()).toString(); + final gap = _number(node.props['gap'] ?? node.layout['gap'], 0); + final padding = _number(node.props['padding'] ?? node.layout['padding'], 0); + final body = _children(axis: split == 'x' ? Axis.horizontal : Axis.vertical); + return Padding( + padding: EdgeInsets.all(padding), + child: split == 'x' + ? LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < 520 && node.props['role'] == 'inspector.row') { + return _column(gap); + } + return body; + }, + ) + : body, + ); + } + + Widget _pane() { + return DecoratedBox( + decoration: BoxDecoration( + color: state.tokens.panel, + border: Border.all(color: state.tokens.border), + borderRadius: BorderRadius.circular(6), + boxShadow: const [ + BoxShadow(color: Color(0x59000000), blurRadius: 26, offset: Offset(0, 12)), + ], + ), + child: Padding( + padding: EdgeInsets.all(_number(node.props['padding'], 12)), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (node.props['title'] != null) + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Text( + node.props['title'].toString().toUpperCase(), + style: TextStyle( + color: state.tokens.muted, + fontFamily: 'EveParity', + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ..._spacedChildren(Axis.vertical, _number(node.props['gap'], 0)), + ], + ), + ), + ); + } + + Widget _card() { + return DecoratedBox( + decoration: BoxDecoration( + color: state.tokens.panelAlt, + border: Border.all(color: const Color(0x10FFFFFF)), + borderRadius: BorderRadius.circular(4), + ), + child: Padding( + padding: const EdgeInsets.all(10), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (node.props['title'] != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + node.props['title'].toString(), + style: TextStyle( + color: state.tokens.muted, + fontFamily: 'EveParity', + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + _children(axis: Axis.vertical), + ], + ), + ), + ); + } + + Widget _text() { + final bind = node.props['bind']?.toString(); + final value = bind == null ? node.props['text'] : state.values[bind]; + final text = '${node.props['prefix'] ?? ''}${value ?? ''}${node.props['suffix'] ?? ''}'; + return Text( + node.kind == 'label' ? text.toUpperCase() : text, + style: TextStyle( + color: node.kind == 'label' ? state.tokens.accent : state.tokens.text, + fontFamily: 'EveParity', + fontSize: node.kind == 'text.title' ? 18 : 13, + fontWeight: node.kind == 'label' ? FontWeight.w700 : FontWeight.w500, + ), + ); + } + + Widget _fallback() { + return node.children.isEmpty ? const SizedBox.shrink() : _children(axis: Axis.vertical); + } + + Widget _children({required Axis axis, double? maxWidth}) { + final gap = _number(node.props['gap'], 0); + final childWidgets = _spacedChildren(axis, gap); + final content = axis == Axis.horizontal + ? Row(crossAxisAlignment: CrossAxisAlignment.center, children: childWidgets) + : Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: childWidgets); + final constrained = maxWidth == null + ? content + : Align( + alignment: Alignment.topLeft, + child: ConstrainedBox(constraints: BoxConstraints(maxWidth: maxWidth), child: content), + ); + return constrained; + } + + Widget _column(double gap) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: _spacedChildren(Axis.vertical, gap), + ); + } + + List _spacedChildren(Axis axis, double gap) { + final widgets = []; + for (var index = 0; index < node.children.length; index += 1) { + final child = node.children[index]; + Widget view = EveNodeView(state: state, node: child); + if (axis == Axis.horizontal && _isFlexible(child)) { + view = Expanded(child: view); + } + widgets.add(view); + if (gap > 0 && index != node.children.length - 1) { + widgets.add(axis == Axis.horizontal ? SizedBox(width: gap) : SizedBox(height: gap)); + } + } + return widgets; + } + + bool _isFlexible(EveNode child) { + final size = child.props['size']?.toString(); + return size == null || size.endsWith('fr'); + } + + String _layoutDirection() { + final direction = node.layout['direction']?.toString(); + if (direction == 'horizontal') { + return 'x'; + } + return 'y'; + } + + Map _map(Object? value) => value is Map ? value : const {}; +} + +class EveGridPainter extends CustomPainter { + EveGridPainter({required this.color}); + + final Color color; @override void paint(Canvas canvas, Size size) { - const accent = Color(0xFFFFB84F); - final centerY = size.height / 2; - final track = RRect.fromRectAndRadius( - Rect.fromLTWH(0, centerY - 3, size.width, 6), - const Radius.circular(2), - ); - final fill = RRect.fromRectAndRadius( - Rect.fromLTWH(0, centerY - 3, size.width * value, 6), - const Radius.circular(2), - ); - final trackPaint = Paint()..color = const Color(0x57000000); - final fillPaint = Paint()..color = accent; - final thumbPaint = Paint() - ..color = accent - ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3); - final thumbCorePaint = Paint()..color = accent; - - canvas.drawRRect(track, trackPaint); - canvas.drawRRect(fill, fillPaint); - final thumbCenter = Offset(size.width * value, centerY); - canvas.drawCircle(thumbCenter, 7, thumbPaint); - canvas.drawCircle(thumbCenter, 6, thumbCorePaint); + final paint = Paint()..color = color; + for (var x = 0.0; x < size.width; x += 32) { + canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint); + } + for (var y = 0.0; y < size.height; y += 32) { + canvas.drawLine(Offset(0, y), Offset(size.width, y), paint); + } } @override - bool shouldRepaint(covariant _SliderPainter oldDelegate) { - return oldDelegate.value != value; + bool shouldRepaint(EveGridPainter oldDelegate) => oldDelegate.color != color; +} + +class EveNornPainter extends CustomPainter { + EveNornPainter({required this.nodes, required this.edges, required this.tokens}); + + final List nodes; + final List edges; + final EveTokens tokens; + + @override + void paint(Canvas canvas, Size size) { + final nodeById = {}; + for (final raw in nodes) { + if (raw is! Map) continue; + final id = raw['id']?.toString(); + if (id == null) continue; + nodeById[id] = Offset(_number(raw['x'], 0.5) * size.width, _number(raw['y'], 0.5) * size.height); + } + final edgePaint = Paint() + ..color = tokens.muted.withValues(alpha: 0.5) + ..strokeWidth = 2; + for (final raw in edges) { + if (raw is! Map) continue; + final source = nodeById[raw['source']?.toString()]; + final target = nodeById[raw['target']?.toString()]; + if (source != null && target != null) { + canvas.drawLine(source, target, edgePaint); + } + } + for (final raw in nodes) { + if (raw is! Map) continue; + final id = raw['id']?.toString(); + final center = id == null ? null : nodeById[id]; + if (center == null) continue; + final current = raw['current'] == true; + canvas.drawCircle(center, current ? 24 : 20, Paint()..color = current ? tokens.accent : tokens.panelAlt); + canvas.drawCircle( + center, + current ? 24 : 20, + Paint() + ..color = current ? tokens.accent : tokens.border + ..style = PaintingStyle.stroke + ..strokeWidth = 2, + ); + final label = raw['label']?.toString() ?? id; + final paragraph = TextPainter( + text: TextSpan( + text: label, + style: TextStyle(color: tokens.text, fontFamily: 'EveParity', fontSize: 13, fontWeight: FontWeight.w700), + ), + textDirection: TextDirection.ltr, + )..layout(maxWidth: 100); + paragraph.paint(canvas, center + Offset(-paragraph.width / 2, 30)); + } + } + + @override + bool shouldRepaint(EveNornPainter oldDelegate) => oldDelegate.nodes != nodes || oldDelegate.edges != edges; +} + +class EveSlider extends StatelessWidget { + const EveSlider({required this.state, required this.node, super.key}); + + final EveSurfaceState state; + final EveNode node; + + @override + Widget build(BuildContext context) { + final bind = node.props['bind']?.toString(); + final min = _number(node.props['min'], 0); + final max = _number(node.props['max'], 1); + final rawValue = bind == null ? node.props['value'] : state.values[bind]; + final value = _number(rawValue, min).clamp(min, max); + final percent = max == min ? 0.0 : ((value - min) / (max - min)).clamp(0.0, 1.0); + final skin = state.controlSkins[node.props['skin']?.toString()] ?? const []; + final box = skin.where((part) => part.kind == 'control.box').firstOrNull?.props ?? const {}; + return CustomPaint( + painter: EveSliderPainter( + value: percent, + tokens: state.tokens, + skin: skin, + ), + child: SizedBox( + height: _number(box['height'], 22).clamp(18, 36), + width: double.infinity, + ), + ); } } + +class EveSliderPainter extends CustomPainter { + EveSliderPainter({required this.value, required this.tokens, required this.skin}); + + final double value; + final EveTokens tokens; + final List skin; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final trackHeight = _partSize('track', 1, 6); + final thumbSize = _partSize('thumb', 0, 12); + final track = RRect.fromRectAndRadius( + Rect.fromLTWH(0, centerY - trackHeight / 2, size.width, trackHeight), + Radius.circular(_partRadius('track', 2)), + ); + final fill = RRect.fromRectAndRadius( + Rect.fromLTWH(0, centerY - trackHeight / 2, size.width * value, trackHeight), + Radius.circular(_partRadius('fill', 2)), + ); + + canvas.drawRRect(track, Paint()..color = tokens.panelInset); + canvas.drawRRect(fill, Paint()..color = tokens.accent); + final thumbCenter = Offset(size.width * value, centerY); + canvas.drawCircle( + thumbCenter, + thumbSize / 2 + _partBleed('thumb', 0), + Paint() + ..color = tokens.accent.withValues(alpha: 0.35) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 5), + ); + canvas.drawCircle(thumbCenter, thumbSize / 2, Paint()..color = tokens.accent); + } + + double _partSize(String name, int index, double fallback) { + final part = _part(name); + final size = part?['size']; + if (size is List && size.length > index) return _number(size[index], fallback); + return fallback; + } + + double _partRadius(String name, double fallback) => _number(_part(name)?['radius'], fallback); + double _partBleed(String name, double fallback) => _number(_part(name)?['bleed'], fallback); + + Map? _part(String name) { + for (final node in skin) { + if (node.kind == 'control.part' && node.props['name'] == name) return node.props; + } + return null; + } + + @override + bool shouldRepaint(covariant EveSliderPainter oldDelegate) { + return oldDelegate.value != value || oldDelegate.tokens != tokens || oldDelegate.skin != skin; + } +} + +double _number(Object? value, double fallback) { + if (value is num) return value.toDouble(); + if (value is String) return double.tryParse(value) ?? fallback; + return fallback; +} diff --git a/flutter/eve_parity/pubspec.yaml b/flutter/eve_parity/pubspec.yaml index 7e275dd..a2fe3ea 100644 --- a/flutter/eve_parity/pubspec.yaml +++ b/flutter/eve_parity/pubspec.yaml @@ -51,6 +51,16 @@ flutter: # the material Icons class. uses-material-design: true + assets: + - assets/current-surface.json + + fonts: + - family: EveParity + fonts: + - asset: assets/fonts/Roboto-Regular.ttf + - asset: assets/fonts/Roboto-Bold.ttf + weight: 700 + # To add assets to your application, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg diff --git a/flutter/eve_parity/test/goldens/cultui-inspector-desktop.png b/flutter/eve_parity/test/goldens/cultui-inspector-desktop.png index 3759659..121d094 100644 Binary files a/flutter/eve_parity/test/goldens/cultui-inspector-desktop.png and b/flutter/eve_parity/test/goldens/cultui-inspector-desktop.png differ diff --git a/flutter/eve_parity/test/goldens/cultui-inspector-phone.png b/flutter/eve_parity/test/goldens/cultui-inspector-phone.png index 0670fa5..aa1db4d 100644 Binary files a/flutter/eve_parity/test/goldens/cultui-inspector-phone.png and b/flutter/eve_parity/test/goldens/cultui-inspector-phone.png differ diff --git a/flutter/eve_parity/test/goldens/cultui-inspector-tablet.png b/flutter/eve_parity/test/goldens/cultui-inspector-tablet.png index 5802052..6f2d36f 100644 Binary files a/flutter/eve_parity/test/goldens/cultui-inspector-tablet.png and b/flutter/eve_parity/test/goldens/cultui-inspector-tablet.png differ diff --git a/flutter/eve_parity/test/goldens/gamecult.home.vn-desktop.png b/flutter/eve_parity/test/goldens/gamecult.home.vn-desktop.png new file mode 100644 index 0000000..61f5cda Binary files /dev/null and b/flutter/eve_parity/test/goldens/gamecult.home.vn-desktop.png differ diff --git a/flutter/eve_parity/test/goldens/gamecult.home.vn-phone.png b/flutter/eve_parity/test/goldens/gamecult.home.vn-phone.png new file mode 100644 index 0000000..b696c39 Binary files /dev/null and b/flutter/eve_parity/test/goldens/gamecult.home.vn-phone.png differ diff --git a/flutter/eve_parity/test/goldens/gamecult.home.vn-tablet.png b/flutter/eve_parity/test/goldens/gamecult.home.vn-tablet.png new file mode 100644 index 0000000..78af213 Binary files /dev/null and b/flutter/eve_parity/test/goldens/gamecult.home.vn-tablet.png differ diff --git a/flutter/eve_parity/test/goldens/repixelizer-desktop.png b/flutter/eve_parity/test/goldens/repixelizer-desktop.png new file mode 100644 index 0000000..93857e5 Binary files /dev/null and b/flutter/eve_parity/test/goldens/repixelizer-desktop.png differ diff --git a/flutter/eve_parity/test/goldens/repixelizer-phone.png b/flutter/eve_parity/test/goldens/repixelizer-phone.png new file mode 100644 index 0000000..adba2eb Binary files /dev/null and b/flutter/eve_parity/test/goldens/repixelizer-phone.png differ diff --git a/flutter/eve_parity/test/goldens/repixelizer-tablet.png b/flutter/eve_parity/test/goldens/repixelizer-tablet.png new file mode 100644 index 0000000..612d3ef Binary files /dev/null and b/flutter/eve_parity/test/goldens/repixelizer-tablet.png differ diff --git a/flutter/eve_parity/test/parity_smoke_test.dart b/flutter/eve_parity/test/parity_smoke_test.dart index c51f2f8..116b656 100644 --- a/flutter/eve_parity/test/parity_smoke_test.dart +++ b/flutter/eve_parity/test/parity_smoke_test.dart @@ -1,22 +1,44 @@ import 'package:eve_parity/main.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + final regular = FontLoader('EveParity') + ..addFont(rootBundle.load('assets/fonts/Roboto-Regular.ttf')) + ..addFont(rootBundle.load('assets/fonts/Roboto-Bold.ttf')); + await regular.load(); + }); + for (final viewport in _viewports) { - testWidgets('renders CultUI inspector parity fixture at ${viewport.id}', (tester) async { + testWidgets('renders selected Eve parity fixture at ${viewport.id}', (tester) async { await tester.binding.setSurfaceSize(Size(viewport.width, viewport.height)); - await tester.pumpWidget(const EveParityApp()); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(EveParityApp(key: ValueKey(viewport.id))); + await tester.pump(const Duration(milliseconds: 100)); await tester.pumpAndSettle(); + await tester.pumpAndSettle(); + expect(find.byType(EveSurfaceView), findsOneWidget); + if (fixtureId == 'cultui-inspector') { + expect(find.text('AETHERIC FIELD TESTER'), findsOneWidget); + expect(find.text('FOV'), findsOneWidget); + expect(find.text('THROTTLE DECAY'), findsOneWidget); + expect(find.text('FLOW SPEED'), findsOneWidget); + } await expectLater( find.byType(EveParityApp), - matchesGoldenFile('goldens/cultui-inspector-${viewport.id}.png'), + matchesGoldenFile('goldens/$fixtureId-${viewport.id}.png'), ); }); } } +const fixtureId = String.fromEnvironment('EVE_PARITY_FIXTURE', defaultValue: 'cultui-inspector'); + const _viewports = [ _Viewport('phone', 390, 844), _Viewport('tablet', 768, 1024), diff --git a/scripts/build-android.ps1 b/scripts/build-android.ps1 index ce06966..1483f2e 100644 --- a/scripts/build-android.ps1 +++ b/scripts/build-android.ps1 @@ -94,6 +94,9 @@ if ($kotlinSources.Count -gt 0) { } Invoke-Checked $jar @("cf", $classesJar, "-C", $classes, ".") Invoke-Checked $d8 @("--lib", $androidJar, "--output", $dex, $classesJar, $cultMeshJar, $kotlinStdlib) +if (Test-Path (Join-Path $androidRoot "src\main\assets")) { + Invoke-Checked $jar @("uf", $unsigned, "-C", (Join-Path $androidRoot "src\main"), "assets") +} Invoke-Checked $jar @("uf", $unsigned, "-C", $dex, "classes.dex") Invoke-Checked $zipalign @("-f", "4", $unsigned, $aligned) diff --git a/scripts/capture-android-screenshot.ps1 b/scripts/capture-android-screenshot.ps1 index 451345a..c77a0f7 100644 --- a/scripts/capture-android-screenshot.ps1 +++ b/scripts/capture-android-screenshot.ps1 @@ -8,6 +8,7 @@ param( [int] $Height = 0, [ValidateSet("portrait", "landscape", "natural")] [string] $Orientation = "natural", + [switch] $UseFixture, [switch] $ForceInstall ) @@ -61,7 +62,11 @@ try { } } - adb shell am start -n "$PackageName/$ActivityName" | Out-Host + $launchArgs = @("shell", "am", "start", "-n", "$PackageName/$ActivityName") + if ($UseFixture) { + $launchArgs += @("--ez", "org.gamecult.eve.PARITY_FIXTURE", "true") + } + adb @launchArgs | Out-Host if ($LASTEXITCODE -ne 0) { throw "adb launch failed with exit code $LASTEXITCODE" } diff --git a/scripts/capture-eve-screenshot.ps1 b/scripts/capture-eve-screenshot.ps1 index 4eb5d80..e082610 100644 --- a/scripts/capture-eve-screenshot.ps1 +++ b/scripts/capture-eve-screenshot.ps1 @@ -1,7 +1,8 @@ param( [string] $Target = "eve", [string] $OutputDirectory = "artifacts\screenshots", - [string] $BundleId = "org.gamecult.evecanvas" + [string] $BundleId = "org.gamecult.evecanvas", + [string] $FixturePath = "" ) $ErrorActionPreference = "Stop" @@ -19,10 +20,17 @@ $localPath = Join-Path $outputRoot "eve-$stamp.png" $remoteDir = "/var/mobile/Library/EveCanvas" $remotePath = "$remoteDir/latest-screenshot.png" $requestPath = "$remoteDir/capture-request" +$remoteFixturePath = "$remoteDir/current-surface.json" ssh $Target "uiopen --bundleid '$BundleId' >/dev/null 2>&1 || true" Start-Sleep -Seconds 1 -ssh $Target "mkdir -p '$remoteDir' && chown mobile:mobile '$remoteDir' && rm -f '$remotePath' '$requestPath' && touch '$requestPath' && chown mobile:mobile '$requestPath' 2>/dev/null || true" +ssh $Target "mkdir -p '$remoteDir' && chown mobile:mobile '$remoteDir' && rm -f '$remotePath' '$requestPath' && chown mobile:mobile '$remoteDir' 2>/dev/null || true" +if ($FixturePath) { + scp $FixturePath "${Target}:$remoteFixturePath" | Out-Host +} else { + ssh $Target "rm -f '$remoteFixturePath'" +} +ssh $Target "touch '$requestPath' && chown mobile:mobile '$requestPath' 2>/dev/null || true" $deadline = (Get-Date).AddSeconds(10) do { diff --git a/scripts/capture-flutter-parity.ps1 b/scripts/capture-flutter-parity.ps1 index 91b106e..1a8b623 100644 --- a/scripts/capture-flutter-parity.ps1 +++ b/scripts/capture-flutter-parity.ps1 @@ -3,6 +3,7 @@ param( [string] $Target = "windows", [ValidateSet("phone", "tablet", "desktop")] [string] $ViewportId = "desktop", + [string] $FixtureId = "cultui-inspector", [string] $OutputPath = "artifacts\parity\flutter-windows-cultui-inspector.png" ) @@ -11,8 +12,12 @@ $ErrorActionPreference = "Stop" $projectRoot = Split-Path -Parent $PSScriptRoot $flutterRoot = Join-Path $projectRoot "flutter\eve_parity" $flutter = Join-Path $projectRoot "tools\deps\flutter\bin\flutter.bat" +$assetPath = Join-Path $flutterRoot "assets\current-surface.json" +$fontDir = Join-Path $flutterRoot "assets\fonts" $absoluteOutput = if ([System.IO.Path]::IsPathRooted($OutputPath)) { $OutputPath } else { Join-Path $projectRoot $OutputPath } New-Item -ItemType Directory -Force (Split-Path -Parent $absoluteOutput) | Out-Null +New-Item -ItemType Directory -Force (Split-Path -Parent $assetPath) | Out-Null +New-Item -ItemType Directory -Force $fontDir | Out-Null if (-not (Test-Path $flutter)) { throw "Flutter is not installed at $flutter" @@ -22,9 +27,17 @@ if ($Target -eq "linux" -and $env:OS -like "Windows*") { throw "Linux Flutter screenshot requires a Linux runner; this host is Windows." } +Copy-Item -LiteralPath (Join-Path $projectRoot "tools\deps\flutter\bin\cache\artifacts\material_fonts\roboto-regular.ttf") -Destination (Join-Path $fontDir "Roboto-Regular.ttf") -Force +Copy-Item -LiteralPath (Join-Path $projectRoot "tools\deps\flutter\bin\cache\artifacts\material_fonts\roboto-bold.ttf") -Destination (Join-Path $fontDir "Roboto-Bold.ttf") -Force + +node .\tools\parity\export-fixture.mjs $FixtureId $assetPath | Out-Host +if ($LASTEXITCODE -ne 0) { + throw "Fixture export failed with exit code $LASTEXITCODE" +} + Push-Location $flutterRoot try { - & $flutter test --update-goldens + & $flutter test --update-goldens "--dart-define=EVE_PARITY_FIXTURE=$FixtureId" --plain-name $ViewportId if ($LASTEXITCODE -ne 0) { throw "Flutter golden smoke failed with exit code $LASTEXITCODE" } @@ -32,7 +45,7 @@ try { Pop-Location } -$golden = Join-Path $flutterRoot "test\goldens\cultui-inspector-$ViewportId.png" +$golden = Join-Path $flutterRoot "test\goldens\$FixtureId-$ViewportId.png" if (-not (Test-Path $golden) -or (Get-Item $golden).Length -le 0) { throw "Flutter golden did not produce a PNG: $golden" } diff --git a/scripts/capture-linux-flutter-parity.ps1 b/scripts/capture-linux-flutter-parity.ps1 index c678950..4a3aa44 100644 --- a/scripts/capture-linux-flutter-parity.ps1 +++ b/scripts/capture-linux-flutter-parity.ps1 @@ -2,6 +2,7 @@ param( [string] $SshTarget = "nightwing", [ValidateSet("phone", "tablet", "desktop")] [string] $ViewportId = "desktop", + [string] $FixtureId = "cultui-inspector", [string] $OutputPath = "artifacts\parity\linux-flutter-cultui-inspector.png", [string] $RemoteRoot = "~/.local/share/gamecult/eve-parity-runner", [string] $RemoteFlutter = "~/.local/share/gamecult/flutter/bin/flutter" @@ -11,14 +12,25 @@ $ErrorActionPreference = "Stop" $projectRoot = Split-Path -Parent $PSScriptRoot $sourceRoot = Join-Path $projectRoot "flutter\eve_parity" +$assetPath = Join-Path $sourceRoot "assets\current-surface.json" +$fontDir = Join-Path $sourceRoot "assets\fonts" $absoluteOutput = if ([System.IO.Path]::IsPathRooted($OutputPath)) { $OutputPath } else { Join-Path $projectRoot $OutputPath } $archivePath = Join-Path ([System.IO.Path]::GetTempPath()) "eve-parity-linux-$([guid]::NewGuid()).tar" New-Item -ItemType Directory -Force (Split-Path -Parent $absoluteOutput) | Out-Null +New-Item -ItemType Directory -Force (Split-Path -Parent $assetPath) | Out-Null +New-Item -ItemType Directory -Force $fontDir | Out-Null if (-not (Test-Path $sourceRoot)) { throw "Flutter parity source not found: $sourceRoot" } +node .\tools\parity\export-fixture.mjs $FixtureId $assetPath | Out-Host +if ($LASTEXITCODE -ne 0) { + throw "Fixture export failed with exit code $LASTEXITCODE" +} +Copy-Item -LiteralPath (Join-Path $projectRoot "tools\deps\flutter\bin\cache\artifacts\material_fonts\roboto-regular.ttf") -Destination (Join-Path $fontDir "Roboto-Regular.ttf") -Force +Copy-Item -LiteralPath (Join-Path $projectRoot "tools\deps\flutter\bin\cache\artifacts\material_fonts\roboto-bold.ttf") -Destination (Join-Path $fontDir "Roboto-Bold.ttf") -Force + try { Push-Location $sourceRoot try { @@ -33,12 +45,12 @@ try { ssh $SshTarget "rm -rf $RemoteRoot && mkdir -p $RemoteRoot" scp $archivePath "${SshTarget}:/tmp/eve-parity-linux.tar" | Out-Host ssh $SshTarget "tar -xf /tmp/eve-parity-linux.tar -C $RemoteRoot && rm /tmp/eve-parity-linux.tar" - ssh $SshTarget "cd $RemoteRoot && $RemoteFlutter test --update-goldens" | Out-Host + ssh $SshTarget "cd $RemoteRoot && $RemoteFlutter test --update-goldens --dart-define=EVE_PARITY_FIXTURE=$FixtureId --plain-name $ViewportId" | Out-Host if ($LASTEXITCODE -ne 0) { throw "Nightwing Flutter golden smoke failed with exit code $LASTEXITCODE" } - $remoteGolden = "$RemoteRoot/test/goldens/cultui-inspector-$ViewportId.png" + $remoteGolden = "$RemoteRoot/test/goldens/$FixtureId-$ViewportId.png" scp "${SshTarget}:$remoteGolden" $absoluteOutput | Out-Host if ($LASTEXITCODE -ne 0) { throw "scp from Nightwing failed with exit code $LASTEXITCODE" diff --git a/scripts/run-parity-smoke.ps1 b/scripts/run-parity-smoke.ps1 index 0966247..04a7226 100644 --- a/scripts/run-parity-smoke.ps1 +++ b/scripts/run-parity-smoke.ps1 @@ -74,9 +74,19 @@ try { } Invoke-Capture "ios" { + $iosFixture = Join-Path $runRoot "ios-current-surface.json" + node .\tools\parity\export-fixture.mjs $ProviderId $iosFixture | Out-Host powershell -ExecutionPolicy Bypass -File .\scripts\capture-eve-screenshot.ps1 ` -Target $IosSshTarget ` - -OutputDirectory $runRoot + -OutputDirectory $runRoot ` + -FixturePath $iosFixture + } + + New-Item -ItemType Directory -Force .\android\app\src\main\assets | Out-Null + node .\tools\parity\export-fixture.mjs $ProviderId .\android\app\src\main\assets\current-surface.json | Out-Host + powershell -ExecutionPolicy Bypass -File .\scripts\build-android.ps1 | Out-Host + if ($LASTEXITCODE -ne 0) { + throw "Android fixture APK build failed with exit code $LASTEXITCODE" } foreach ($case in $responsiveCases) { @@ -85,7 +95,9 @@ try { -OutputPath (Join-Path $runRoot "android-periwinkle-$($case.id).png") ` -Width $case.width ` -Height $case.height ` - -Orientation $case.orientation + -Orientation $case.orientation ` + -UseFixture ` + -ForceInstall } } @@ -93,7 +105,8 @@ try { Invoke-Capture "android-$($case.id)" { powershell -ExecutionPolicy Bypass -File .\scripts\capture-android-screenshot.ps1 ` -OutputPath (Join-Path $runRoot "android-periwinkle-$($case.id).png") ` - -Orientation $case.orientation + -Orientation $case.orientation ` + -UseFixture } } @@ -101,8 +114,9 @@ try { Invoke-Capture "windows-$($case.id)" { powershell -ExecutionPolicy Bypass -File .\scripts\capture-flutter-parity.ps1 ` -Target windows ` + -FixtureId $ProviderId ` -ViewportId $case.id ` - -OutputPath (Join-Path $runRoot "windows-flutter-cultui-inspector-$($case.id).png") + -OutputPath (Join-Path $runRoot "windows-flutter-$ProviderId-$($case.id).png") } } @@ -110,8 +124,9 @@ try { Invoke-Capture "linux-$($case.id)" { powershell -ExecutionPolicy Bypass -File .\scripts\capture-linux-flutter-parity.ps1 ` -SshTarget $LinuxSshTarget ` + -FixtureId $ProviderId ` -ViewportId $case.id ` - -OutputPath (Join-Path $runRoot "linux-flutter-cultui-inspector-$($case.id).png") + -OutputPath (Join-Path $runRoot "linux-flutter-$ProviderId-$($case.id).png") } } diff --git a/scripts/stage-to-eve.ps1 b/scripts/stage-to-eve.ps1 index 8977005..59ffb44 100644 --- a/scripts/stage-to-eve.ps1 +++ b/scripts/stage-to-eve.ps1 @@ -15,6 +15,13 @@ if (Test-Path $archive) { tar ` --exclude=.git ` --exclude=.theos ` + --exclude=android ` + --exclude=artifacts ` + --exclude=docs ` + --exclude=flutter ` + --exclude=tools ` + --exclude=web ` + --exclude=.voidbot ` --exclude=packages ` -cf $archive ` -C $projectRoot . diff --git a/tools/parity/export-fixture.mjs b/tools/parity/export-fixture.mjs new file mode 100644 index 0000000..9fd34cf --- /dev/null +++ b/tools/parity/export-fixture.mjs @@ -0,0 +1,36 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { compileEveDsl } from "../../web/eve-dsl.js"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const manifest = JSON.parse(await readFile(path.join(repoRoot, "tools/parity/parity-manifest.json"), "utf8")); + +const fixtureId = process.argv[2] || "cultui-inspector"; +const outputPath = process.argv[3] + ? path.resolve(process.argv[3]) + : path.join(repoRoot, "artifacts/parity/fixtures", `${fixtureId}.surface.json`); + +const fixture = manifest.fixtures.find(candidate => + candidate.id === fixtureId || candidate.expect?.providerId === fixtureId); + +if (!fixture) { + throw new Error(`Unknown parity fixture: ${fixtureId}`); +} + +const surface = await loadSurface(fixture.surface); +surface.fixtureId = fixture.id; +surface.values ||= surface.mesh?.snapshot?.() || {}; +delete surface.mesh; + +await mkdir(path.dirname(outputPath), { recursive: true }); +await writeFile(outputPath, `${JSON.stringify(surface, null, 2)}\n`); +console.log(outputPath); + +async function loadSurface(surface) { + const absolutePath = path.join(repoRoot, surface.path); + const source = await readFile(absolutePath, "utf8"); + if (surface.transport === "local-eve-dsl") return compileEveDsl(source); + if (surface.transport === "local-json") return JSON.parse(source); + throw new Error(`Unsupported parity surface transport: ${surface.transport}`); +} diff --git a/tools/parity/parity-manifest.json b/tools/parity/parity-manifest.json index 5f32287..6001410 100644 --- a/tools/parity/parity-manifest.json +++ b/tools/parity/parity-manifest.json @@ -74,6 +74,36 @@ ] } }, + { + "id": "sai-vn", + "title": "Sai VN", + "surface": { + "transport": "local-json", + "path": "web/fixtures/sai-vn-surface.json" + }, + "expect": { + "providerId": "gamecult.home.vn", + "componentKinds": [ + "vn.stage", + "image.background", + "embed.norn", + "embed.tex", + "panel.dialogue", + "rail.actions", + "control.button" + ], + "styleTokens": [ + "colorBackground", + "colorPanel", + "colorPanelAlt", + "colorText", + "colorAccent", + "fontBody", + "fontTitle", + "pixelArt" + ] + } + }, { "id": "reactive-dsl", "title": "Reactive DSL", @@ -125,7 +155,7 @@ { "id": "linux-flutter", "title": "Linux / Flutter", - "kind": "requires-linux-runner", + "kind": "active", "expectedPaths": ["flutter/eve_parity/linux", "scripts/capture-flutter-parity.ps1"], "capture": { "status": "ssh-golden", @@ -135,7 +165,7 @@ { "id": "ios-uikit", "title": "iOS / UIKit", - "kind": "native-proof", + "kind": "active", "expectedPaths": ["Sources/EVEViewController.mm", "scripts/capture-eve-screenshot.ps1"], "capture": { "status": "ssh-png", @@ -145,7 +175,7 @@ { "id": "android-kotlin", "title": "Android / Kotlin", - "kind": "device-proof", + "kind": "active", "expectedPaths": ["android/app/src/main/java/org/gamecult/eve/MainActivity.kt", "scripts/build-android.ps1"], "capture": { "status": "adb-png", @@ -155,7 +185,7 @@ { "id": "direct2d", "title": "Fensalir Direct2D", - "kind": "specialized-native", + "kind": "pending", "expectedPaths": ["web/fixtures/fensalir-client-surface.json"], "capture": { "status": "missing", diff --git a/web/eve-dsl.js b/web/eve-dsl.js index 1336168..4169b0f 100644 --- a/web/eve-dsl.js +++ b/web/eve-dsl.js @@ -131,6 +131,9 @@ export function createCultMeshStore() { }, }; }, + snapshot() { + return Object.fromEntries([...records.entries()].map(([path, record]) => [path, record.value])); + }, derive(target, operator, sourcePath) { const source = this.var(sourcePath); const targetVar = this.var(target); diff --git a/web/surface.js b/web/surface.js index fea8ae8..2911efb 100644 --- a/web/surface.js +++ b/web/surface.js @@ -64,6 +64,7 @@ const localProviders = [ }, { providerId: "sai.visual_novel", + aliases: ["gamecult.home.vn", "sai-vn"], title: "Sai VN Surface", kind: "content.runtime", freshness: { state: "fixture" }, @@ -108,7 +109,7 @@ async function bootProviders() { return option; })); const requestedProviderId = new URLSearchParams(location.search).get("provider"); - const firstProduct = providers.find(provider => provider.providerId === requestedProviderId) + const firstProduct = providers.find(provider => provider.providerId === requestedProviderId || provider.aliases?.includes(requestedProviderId)) || providers.find(provider => provider.providerId === "repixelizer") || providers[0]; providerSelect.value = firstProduct.providerId;