Build cross-runtime CultUI parity smoke
3
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, android.graphics.PointF>()
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<EveParityApp> createState() => _EveParityAppState();
|
||||
}
|
||||
|
||||
class _EveParityAppState extends State<EveParityApp> {
|
||||
late Future<EveSurfaceState> 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<EveSurfaceState>(
|
||||
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<String, List<EveNode>> controlSkins;
|
||||
final Map<String, dynamic> values;
|
||||
|
||||
static Future<EveSurfaceState> load(String assetPath) async {
|
||||
final raw = await rootBundle.loadString(assetPath);
|
||||
final json = jsonDecode(raw) as Map<String, dynamic>;
|
||||
final surface = json['surface'] as Map<String, dynamic>;
|
||||
final styles = (surface['styles'] as Map<String, dynamic>?) ?? const {};
|
||||
final skins = <String, List<EveNode>>{};
|
||||
final rawSkins = (styles['controlSkins'] as Map<String, dynamic>?) ?? const {};
|
||||
for (final entry in rawSkins.entries) {
|
||||
final skin = entry.value as Map<String, dynamic>;
|
||||
skins[entry.key] = ((skin['children'] as List<dynamic>?) ?? const [])
|
||||
.map((child) => EveNode.fromJson(child as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
return EveSurfaceState(
|
||||
providerId: (json['providerId'] ?? '').toString(),
|
||||
title: (json['title'] ?? '').toString(),
|
||||
root: EveNode.fromJson(surface['root'] as Map<String, dynamic>),
|
||||
tokens: EveTokens.fromJson((styles['tokens'] as Map<String, dynamic>?) ?? const {}),
|
||||
controlSkins: skins,
|
||||
values: (json['values'] as Map<String, dynamic>?) ?? 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<String, dynamic> props;
|
||||
final Map<String, dynamic> layout;
|
||||
final List<EveNode> children;
|
||||
|
||||
factory EveNode.fromJson(Map<String, dynamic> json) {
|
||||
return EveNode(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
kind: (json['kind'] ?? 'panel').toString(),
|
||||
props: (json['props'] as Map<String, dynamic>?) ?? const {},
|
||||
layout: (json['layout'] as Map<String, dynamic>?) ?? const {},
|
||||
children: ((json['children'] as List<dynamic>?) ?? const [])
|
||||
.map((child) => EveNode.fromJson(child as Map<String, dynamic>))
|
||||
.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<String, dynamic> 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<dynamic>?) ?? const [];
|
||||
final edges = (graph['edges'] as List<dynamic>?) ?? 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<Widget> _spacedChildren(Axis axis, double gap) {
|
||||
final widgets = <Widget>[];
|
||||
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<String, dynamic> _map(Object? value) => value is Map<String, dynamic> ? 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<dynamic> nodes;
|
||||
final List<dynamic> edges;
|
||||
final EveTokens tokens;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final nodeById = <String, Offset>{};
|
||||
for (final raw in nodes) {
|
||||
if (raw is! Map<String, dynamic>) 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<String, dynamic>) 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<String, dynamic>) 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<EveNode> 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<String, dynamic>? _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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 62 KiB |
BIN
flutter/eve_parity/test/goldens/gamecult.home.vn-desktop.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
flutter/eve_parity/test/goldens/gamecult.home.vn-phone.png
Normal file
|
After Width: | Height: | Size: 417 KiB |
BIN
flutter/eve_parity/test/goldens/gamecult.home.vn-tablet.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
flutter/eve_parity/test/goldens/repixelizer-desktop.png
Normal file
|
After Width: | Height: | Size: 251 KiB |
BIN
flutter/eve_parity/test/goldens/repixelizer-phone.png
Normal file
|
After Width: | Height: | Size: 206 KiB |
BIN
flutter/eve_parity/test/goldens/repixelizer-tablet.png
Normal file
|
After Width: | Height: | Size: 211 KiB |
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 .
|
||||
|
|
|
|||
36
tools/parity/export-fixture.mjs
Normal file
|
|
@ -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}`);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||