Render native Mimir dashboard

This commit is contained in:
Meta Vens 2026-05-31 01:04:41 +01:00
commit 35afc22092
5 changed files with 520 additions and 2 deletions

View file

@ -11,6 +11,7 @@ EveCanvas_FILES = \
Sources/EVEViewController.mm \
Sources/EVEGLView.mm \
Sources/EVEFrameStreamClient.m \
Sources/EVEDashboardClient.m \
Sources/EVESensorUplinkClient.m \
Sources/EVEH264StreamDecoder.m

View file

@ -28,9 +28,13 @@ the development machine.
- `EVESensorUplinkClient` opens separate WebSocket uplinks for camera and
microphone frame-events so sensor traffic does not block display/control
traffic.
- `EVEDashboardClient` opens the native Mimir dashboard socket and receives
scene/control state snapshots.
- `EVEViewController` captures camera frames with AVFoundation and microphone
blocks with AVAudioEngine, then sends `eve-camera` and `eve-mic` samples to
Mimir.
Mimir. It also renders the dashboard natively with UIKit: a scene graph,
draggable source panels, visibility/reset controls, and multitouch
pan/pinch/rotate transform commands.
## Invariants
@ -69,6 +73,28 @@ uses JSON plus base64 payloads because it is inspectable and already matches
Mimir's frame-event source. Replace it with binary framing only after the
sample contract is proven on device.
## Native Mimir Dashboard
Start the dashboard authority on Starfire:
```powershell
dotnet run --project E:\Projects\Mimir\src\Mimir.EveDashboard\Mimir.EveDashboard.csproj -- --port 8795
```
EveCanvas connects to `ws://192.168.1.66:8795/eve/dashboard`. Mimir sends
`dashboard-state` snapshots containing scene nodes, selection, visibility,
transform, size, and health. EveCanvas sends compact commands back:
- `select`
- `move`
- `scale`
- `rotate`
- `toggle-visibility`
- `reset-transform`
Eve renders and edits the dashboard; Mimir owns the accepted state. Eve is the
operator's hand on the scene graph, not a second compositor.
## Build Shape
The project is a Theos-style iOS application:
@ -126,3 +152,5 @@ without losing the basic app deployment path.
- Add touch/Pencil visual markers.
- Replace JSON/base64 sensor packets with binary framing once camera and mic
timing are proven through Mimir.
- Replace the dashboard fixture state with live `MimirPresentationControlState`
and `MimirSceneEditorState` snapshots.

View file

@ -0,0 +1,21 @@
#import <Foundation/Foundation.h>
@protocol EVEDashboardClientDelegate;
@interface EVEDashboardClient : NSObject
- (instancetype)initWithURLs:(NSArray<NSURL *> *)urls delegate:(id<EVEDashboardClientDelegate>)delegate;
- (void)connect;
- (void)disconnect;
- (void)sendCommand:(NSDictionary *)command;
@property(nonatomic, copy, readonly) NSString *status;
@end
@protocol EVEDashboardClientDelegate <NSObject>
- (void)dashboardClient:(EVEDashboardClient *)client didReceiveState:(NSDictionary *)state;
- (void)dashboardClient:(EVEDashboardClient *)client didChangeStatus:(NSString *)status;
@end

View file

@ -0,0 +1,127 @@
#import "EVEDashboardClient.h"
@interface EVEDashboardClient ()
@property(nonatomic, copy) NSArray<NSURL *> *urls;
@property(nonatomic, assign) NSUInteger urlIndex;
@property(nonatomic, weak) id<EVEDashboardClientDelegate> delegate;
@property(nonatomic, strong) NSURLSession *session;
@property(nonatomic, strong) NSURLSessionWebSocketTask *task;
@property(nonatomic, assign) BOOL shouldReconnect;
@property(nonatomic, copy, readwrite) NSString *status;
@end
@implementation EVEDashboardClient
- (instancetype)initWithURLs:(NSArray<NSURL *> *)urls delegate:(id<EVEDashboardClientDelegate>)delegate {
self = [super init];
if (!self) {
return nil;
}
self.urls = urls.count > 0 ? urls : @[];
self.delegate = delegate;
self.session = [NSURLSession sessionWithConfiguration:NSURLSessionConfiguration.defaultSessionConfiguration];
self.status = @"dashboard idle";
return self;
}
- (void)connect {
self.shouldReconnect = YES;
[self.task cancelWithCloseCode:NSURLSessionWebSocketCloseCodeNormalClosure reason:nil];
NSURL *url = self.urls.count > 0 ? self.urls[self.urlIndex % self.urls.count] : nil;
if (!url) {
[self publishStatus:@"dashboard URL missing"];
return;
}
self.task = [self.session webSocketTaskWithURL:url];
[self.task resume];
[self publishStatus:[NSString stringWithFormat:@"dashboard connecting %@", url.host ?: @""]];
[self receiveNextMessage];
}
- (void)disconnect {
self.shouldReconnect = NO;
[self.task cancelWithCloseCode:NSURLSessionWebSocketCloseCodeNormalClosure reason:nil];
self.task = nil;
}
- (void)sendCommand:(NSDictionary *)command {
if (!self.task) {
return;
}
NSData *json = [NSJSONSerialization dataWithJSONObject:command options:0 error:nil];
if (!json) {
return;
}
NSString *text = [[NSString alloc] initWithData:json encoding:NSUTF8StringEncoding];
NSURLSessionWebSocketMessage *message = [[NSURLSessionWebSocketMessage alloc] initWithString:text];
[self.task sendMessage:message completionHandler:^(NSError *error) {
if (error) {
[self publishStatus:@"dashboard command failed"];
}
}];
}
- (void)receiveNextMessage {
__weak typeof(self) weakSelf = self;
[self.task receiveMessageWithCompletionHandler:^(NSURLSessionWebSocketMessage *message, NSError *error) {
__strong typeof(weakSelf) self = weakSelf;
if (!self) {
return;
}
if (error) {
[self publishStatus:@"dashboard disconnected"];
[self reconnectSoon];
return;
}
if (message.type == NSURLSessionWebSocketMessageTypeString) {
NSData *data = [message.string dataUsingEncoding:NSUTF8StringEncoding];
NSDictionary *state = data ? [NSJSONSerialization JSONObjectWithData:data options:0 error:nil] : nil;
if ([state isKindOfClass:NSDictionary.class]) {
dispatch_async(dispatch_get_main_queue(), ^{
[self.delegate dashboardClient:self didReceiveState:state];
[self.delegate dashboardClient:self didChangeStatus:@"dashboard live"];
});
}
}
[self receiveNextMessage];
}];
}
- (void)reconnectSoon {
if (!self.shouldReconnect) {
return;
}
if (self.urls.count > 1) {
self.urlIndex = (self.urlIndex + 1) % self.urls.count;
}
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
if (self.shouldReconnect) {
[self connect];
}
});
}
- (void)publishStatus:(NSString *)status {
self.status = status ?: @"dashboard";
dispatch_async(dispatch_get_main_queue(), ^{
[self.delegate dashboardClient:self didChangeStatus:self.status];
});
}
- (void)dealloc {
[self disconnect];
[self.session invalidateAndCancel];
}
@end

View file

@ -4,6 +4,7 @@
#import <QuartzCore/QuartzCore.h>
#import "EVEFrameStreamClient.h"
#import "EVEDashboardClient.h"
#import "EVEGLView.h"
#import "EVEH264StreamDecoder.h"
#import "EVESensorUplinkClient.h"
@ -14,15 +15,23 @@ static int64_t EVEHostTimeNowNs(void) {
return (int64_t)(CACurrentMediaTime() * 1000000000.0);
}
@interface EVEViewController () <EVEFrameStreamClientDelegate, AVCaptureVideoDataOutputSampleBufferDelegate>
@interface EVEViewController () <EVEFrameStreamClientDelegate, EVEDashboardClientDelegate, AVCaptureVideoDataOutputSampleBufferDelegate, UIGestureRecognizerDelegate>
@property(nonatomic, strong) EVEGLView *glView;
@property(nonatomic, strong) UIImageView *streamImageView;
@property(nonatomic, strong) EVEH264StreamDecoder *videoDecoder;
@property(nonatomic, strong) UILabel *overlayLabel;
@property(nonatomic, strong) UIView *dashboardView;
@property(nonatomic, strong) UIView *sceneCanvasView;
@property(nonatomic, strong) UIStackView *hierarchyStackView;
@property(nonatomic, strong) UIStackView *toolbarStackView;
@property(nonatomic, strong) UILabel *dashboardStatusLabel;
@property(nonatomic, strong) NSMutableDictionary<NSString *, UIView *> *nodeViews;
@property(nonatomic, strong) NSMutableDictionary<NSString *, NSDictionary *> *dashboardNodes;
@property(nonatomic, strong) CADisplayLink *displayLink;
@property(nonatomic, strong) CMMotionManager *motionManager;
@property(nonatomic, strong) EVEFrameStreamClient *streamClient;
@property(nonatomic, strong) EVEDashboardClient *dashboardClient;
@property(nonatomic, strong) EVESensorUplinkClient *cameraUplink;
@property(nonatomic, strong) EVESensorUplinkClient *micUplink;
@property(nonatomic, strong) AVCaptureSession *captureSession;
@ -39,6 +48,10 @@ static int64_t EVEHostTimeNowNs(void) {
@property(nonatomic, copy) NSString *streamStatus;
@property(nonatomic, copy) NSString *streamCodec;
@property(nonatomic, copy) NSString *dialogueLine;
@property(nonatomic, copy) NSString *dashboardStatus;
@property(nonatomic, copy) NSString *selectedNodeId;
@property(nonatomic, assign) CGFloat activeGestureStartScale;
@property(nonatomic, assign) CGFloat activeGestureStartRotation;
@end
@ -65,6 +78,8 @@ static int64_t EVEHostTimeNowNs(void) {
self.videoDecoder.displayLayer.hidden = YES;
[root.layer addSublayer:self.videoDecoder.displayLayer];
[self installDashboardInRoot:root];
self.overlayLabel = [[UILabel alloc] initWithFrame:CGRectZero];
self.overlayLabel.translatesAutoresizingMaskIntoConstraints = NO;
self.overlayLabel.numberOfLines = 0;
@ -103,6 +118,9 @@ static int64_t EVEHostTimeNowNs(void) {
self.streamStatus = @"stream idle";
self.streamCodec = @"jpeg";
self.dialogueLine = @"awaiting Mimir";
self.dashboardStatus = @"dashboard idle";
self.nodeViews = [NSMutableDictionary dictionary];
self.dashboardNodes = [NSMutableDictionary dictionary];
NSArray<NSURL *> *streamURLs = @[
[NSURL URLWithString:@"ws://127.0.0.1:8792/stream"],
[NSURL URLWithString:@"ws://192.168.1.66:8792/stream"],
@ -110,6 +128,13 @@ static int64_t EVEHostTimeNowNs(void) {
self.streamClient = [[EVEFrameStreamClient alloc] initWithURLs:streamURLs delegate:self];
[self.streamClient connect];
NSArray<NSURL *> *dashboardURLs = @[
[NSURL URLWithString:@"ws://127.0.0.1:8795/eve/dashboard"],
[NSURL URLWithString:@"ws://192.168.1.66:8795/eve/dashboard"],
];
self.dashboardClient = [[EVEDashboardClient alloc] initWithURLs:dashboardURLs delegate:self];
[self.dashboardClient connect];
NSArray<NSURL *> *cameraURLs = @[
[NSURL URLWithString:@"ws://127.0.0.1:8793/eve/camera"],
[NSURL URLWithString:@"ws://192.168.1.66:8793/eve/camera"],
@ -131,6 +156,7 @@ static int64_t EVEHostTimeNowNs(void) {
- (void)dealloc {
[self.displayLink invalidate];
[self.streamClient disconnect];
[self.dashboardClient disconnect];
[self.cameraUplink disconnect];
[self.micUplink disconnect];
[self.captureSession stopRunning];
@ -155,6 +181,9 @@ static int64_t EVEHostTimeNowNs(void) {
[super viewDidLayoutSubviews];
[self.glView resizeDrawableIfNeeded];
self.videoDecoder.displayLayer.frame = self.view.bounds;
for (NSString *nodeId in self.dashboardNodes) {
[self updateDashboardNodeView:self.dashboardNodes[nodeId]];
}
}
- (void)frameTick:(CADisplayLink *)link {
@ -172,6 +201,12 @@ static int64_t EVEHostTimeNowNs(void) {
}
- (void)updateOverlay {
if (self.dashboardView) {
self.overlayLabel.hidden = YES;
return;
}
self.overlayLabel.hidden = NO;
UIScreen *screen = UIScreen.mainScreen;
CGSize points = screen.bounds.size;
CGFloat scale = screen.scale;
@ -186,6 +221,7 @@ static int64_t EVEHostTimeNowNs(void) {
"%@\n"
"codec %@\n"
"Mimir: %@\n"
"dashboard %@\n"
"points %.0fx%.0f pixels %.0fx%.0f @ %.1fx\n"
"stream %.0fx%.0f @ %.1fx\n"
"fps %.1f touches %lu\n"
@ -195,6 +231,7 @@ static int64_t EVEHostTimeNowNs(void) {
self.streamStatus ?: @"stream",
self.streamCodec ?: @"unknown",
self.dialogueLine ?: @"",
self.dashboardStatus ?: @"dashboard",
points.width, points.height, pixels.width, pixels.height, scale,
self.streamViewportSize.width, self.streamViewportSize.height, self.streamScale,
self.filteredFPS, (unsigned long)self.touchCount,
@ -205,6 +242,81 @@ static int64_t EVEHostTimeNowNs(void) {
[self.overlayLabel sizeToFit];
}
- (void)installDashboardInRoot:(UIView *)root {
self.dashboardView = [[UIView alloc] initWithFrame:root.bounds];
self.dashboardView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
self.dashboardView.backgroundColor = [[UIColor colorWithRed:0.02 green:0.025 blue:0.03 alpha:1.0] colorWithAlphaComponent:0.92];
[root addSubview:self.dashboardView];
self.sceneCanvasView = [[UIView alloc] initWithFrame:CGRectZero];
self.sceneCanvasView.translatesAutoresizingMaskIntoConstraints = NO;
self.sceneCanvasView.backgroundColor = [UIColor colorWithRed:0.05 green:0.055 blue:0.06 alpha:1.0];
self.sceneCanvasView.layer.borderColor = [UIColor colorWithWhite:1.0 alpha:0.12].CGColor;
self.sceneCanvasView.layer.borderWidth = 1.0;
self.sceneCanvasView.multipleTouchEnabled = YES;
[self.dashboardView addSubview:self.sceneCanvasView];
self.hierarchyStackView = [[UIStackView alloc] initWithFrame:CGRectZero];
self.hierarchyStackView.translatesAutoresizingMaskIntoConstraints = NO;
self.hierarchyStackView.axis = UILayoutConstraintAxisVertical;
self.hierarchyStackView.spacing = 6.0;
self.hierarchyStackView.alignment = UIStackViewAlignmentFill;
[self.dashboardView addSubview:self.hierarchyStackView];
self.toolbarStackView = [[UIStackView alloc] initWithFrame:CGRectZero];
self.toolbarStackView.translatesAutoresizingMaskIntoConstraints = NO;
self.toolbarStackView.axis = UILayoutConstraintAxisHorizontal;
self.toolbarStackView.spacing = 8.0;
self.toolbarStackView.alignment = UIStackViewAlignmentCenter;
[self.dashboardView addSubview:self.toolbarStackView];
self.dashboardStatusLabel = [[UILabel alloc] initWithFrame:CGRectZero];
self.dashboardStatusLabel.translatesAutoresizingMaskIntoConstraints = NO;
self.dashboardStatusLabel.textColor = [UIColor colorWithWhite:0.90 alpha:1.0];
self.dashboardStatusLabel.font = [UIFont monospacedSystemFontOfSize:12.0 weight:UIFontWeightRegular];
self.dashboardStatusLabel.text = @"dashboard idle";
[self.dashboardView addSubview:self.dashboardStatusLabel];
UILayoutGuide *safe = self.dashboardView.safeAreaLayoutGuide;
[NSLayoutConstraint activateConstraints:@[
[self.hierarchyStackView.leadingAnchor constraintEqualToAnchor:safe.leadingAnchor constant:14.0],
[self.hierarchyStackView.topAnchor constraintEqualToAnchor:safe.topAnchor constant:74.0],
[self.hierarchyStackView.widthAnchor constraintEqualToConstant:230.0],
[self.hierarchyStackView.bottomAnchor constraintLessThanOrEqualToAnchor:safe.bottomAnchor constant:-18.0],
[self.sceneCanvasView.leadingAnchor constraintEqualToAnchor:self.hierarchyStackView.trailingAnchor constant:14.0],
[self.sceneCanvasView.topAnchor constraintEqualToAnchor:safe.topAnchor constant:74.0],
[self.sceneCanvasView.trailingAnchor constraintEqualToAnchor:safe.trailingAnchor constant:-14.0],
[self.sceneCanvasView.bottomAnchor constraintEqualToAnchor:safe.bottomAnchor constant:-64.0],
[self.toolbarStackView.leadingAnchor constraintEqualToAnchor:self.sceneCanvasView.leadingAnchor],
[self.toolbarStackView.topAnchor constraintEqualToAnchor:safe.topAnchor constant:18.0],
[self.toolbarStackView.trailingAnchor constraintLessThanOrEqualToAnchor:safe.trailingAnchor constant:-14.0],
[self.toolbarStackView.heightAnchor constraintEqualToConstant:42.0],
[self.dashboardStatusLabel.leadingAnchor constraintEqualToAnchor:self.sceneCanvasView.leadingAnchor],
[self.dashboardStatusLabel.topAnchor constraintEqualToAnchor:self.sceneCanvasView.bottomAnchor constant:10.0],
[self.dashboardStatusLabel.trailingAnchor constraintEqualToAnchor:self.sceneCanvasView.trailingAnchor],
]];
NSArray<NSDictionary *> *buttons = @[
@{@"title": @"Reset", @"action": @"reset-transform"},
@{@"title": @"Hide/Show", @"action": @"toggle-visibility"},
];
for (NSDictionary *entry in buttons) {
UIButton *button = [UIButton buttonWithType:UIButtonTypeSystem];
[button setTitle:entry[@"title"] forState:UIControlStateNormal];
button.accessibilityIdentifier = entry[@"action"];
button.titleLabel.font = [UIFont systemFontOfSize:15.0 weight:UIFontWeightSemibold];
button.tintColor = UIColor.whiteColor;
button.backgroundColor = [UIColor colorWithRed:0.16 green:0.18 blue:0.20 alpha:1.0];
button.layer.cornerRadius = 6.0;
button.contentEdgeInsets = UIEdgeInsetsMake(8, 12, 8, 12);
[button addTarget:self action:@selector(toolbarButtonPressed:) forControlEvents:UIControlEventTouchUpInside];
[self.toolbarStackView addArrangedSubview:button];
}
}
- (void)startSensorCapture {
self.captureQueue = dispatch_queue_create("org.gamecult.evecanvas.camera", DISPATCH_QUEUE_SERIAL);
@ -401,6 +513,235 @@ static int64_t EVEHostTimeNowNs(void) {
return CGPointMake(x, y);
}
- (void)dashboardClient:(EVEDashboardClient *)client didReceiveState:(NSDictionary *)state {
(void)client;
self.dashboardStatus = [NSString stringWithFormat:@"v%@", state[@"version"] ?: @"?"];
NSString *selected = state[@"selectedNodeId"];
if ([selected isKindOfClass:NSString.class]) {
self.selectedNodeId = selected;
}
NSArray *nodes = state[@"nodes"];
if (![nodes isKindOfClass:NSArray.class]) {
return;
}
[self.dashboardNodes removeAllObjects];
for (NSDictionary *node in nodes) {
if (![node isKindOfClass:NSDictionary.class]) {
continue;
}
NSString *nodeId = node[@"id"];
if (![nodeId isKindOfClass:NSString.class]) {
continue;
}
self.dashboardNodes[nodeId] = node;
[self ensureDashboardNodeView:node];
[self updateDashboardNodeView:node];
}
[self rebuildHierarchyWithNodes:nodes];
}
- (void)dashboardClient:(EVEDashboardClient *)client didChangeStatus:(NSString *)status {
(void)client;
self.dashboardStatus = status;
self.dashboardStatusLabel.text = status;
}
- (void)ensureDashboardNodeView:(NSDictionary *)node {
NSString *nodeId = node[@"id"];
if (self.nodeViews[nodeId]) {
return;
}
UIView *view = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 120, 80)];
view.backgroundColor = [UIColor colorWithRed:0.10 green:0.13 blue:0.16 alpha:1.0];
view.layer.borderWidth = 1.0;
view.layer.cornerRadius = 6.0;
view.multipleTouchEnabled = YES;
view.accessibilityIdentifier = nodeId;
UILabel *label = [[UILabel alloc] initWithFrame:CGRectInset(view.bounds, 8.0, 6.0)];
label.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
label.numberOfLines = 3;
label.textColor = UIColor.whiteColor;
label.font = [UIFont monospacedSystemFontOfSize:12.0 weight:UIFontWeightMedium];
label.tag = 1001;
[view addSubview:label];
UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(nodeTapped:)];
[view addGestureRecognizer:tap];
UIPanGestureRecognizer *pan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(nodePanned:)];
[view addGestureRecognizer:pan];
UIPinchGestureRecognizer *pinch = [[UIPinchGestureRecognizer alloc] initWithTarget:self action:@selector(nodePinched:)];
pinch.delegate = self;
[view addGestureRecognizer:pinch];
UIRotationGestureRecognizer *rotation = [[UIRotationGestureRecognizer alloc] initWithTarget:self action:@selector(nodeRotated:)];
rotation.delegate = self;
[view addGestureRecognizer:rotation];
[self.sceneCanvasView addSubview:view];
self.nodeViews[nodeId] = view;
}
- (void)updateDashboardNodeView:(NSDictionary *)node {
NSString *nodeId = node[@"id"];
UIView *view = self.nodeViews[nodeId];
if (!view) {
return;
}
CGFloat canvasWidth = MAX(1.0, self.sceneCanvasView.bounds.size.width);
CGFloat canvasHeight = MAX(1.0, self.sceneCanvasView.bounds.size.height);
CGFloat width = MAX(72.0, [node[@"width"] doubleValue] * canvasWidth);
CGFloat height = MAX(54.0, [node[@"height"] doubleValue] * canvasHeight);
CGFloat x = ([node[@"x"] doubleValue] + 1.0) * 0.5 * canvasWidth;
CGFloat y = ([node[@"y"] doubleValue] + 1.0) * 0.5 * canvasHeight;
view.bounds = CGRectMake(0, 0, width, height);
view.center = CGPointMake(x, y);
CGFloat scale = MAX(0.25, [node[@"scale"] doubleValue]);
CGFloat rotation = [node[@"rotation"] doubleValue];
view.transform = CGAffineTransformRotate(CGAffineTransformMakeScale(scale, scale), rotation);
BOOL visible = [node[@"visible"] boolValue];
view.alpha = visible ? 1.0 : 0.28;
BOOL selected = [nodeId isEqualToString:self.selectedNodeId];
view.layer.borderColor = selected
? [UIColor colorWithRed:0.45 green:0.80 blue:1.0 alpha:1.0].CGColor
: [UIColor colorWithWhite:1.0 alpha:0.16].CGColor;
view.layer.borderWidth = selected ? 2.0 : 1.0;
UILabel *label = (UILabel *)[view viewWithTag:1001];
if ([label isKindOfClass:UILabel.class]) {
label.text = [NSString stringWithFormat:@"%@\n%@ %@",
node[@"label"] ?: nodeId,
node[@"kind"] ?: @"source",
node[@"health"] ?: @""];
}
}
- (void)rebuildHierarchyWithNodes:(NSArray *)nodes {
for (UIView *view in self.hierarchyStackView.arrangedSubviews) {
[self.hierarchyStackView removeArrangedSubview:view];
[view removeFromSuperview];
}
UILabel *title = [[UILabel alloc] initWithFrame:CGRectZero];
title.text = @"Scene Graph";
title.textColor = UIColor.whiteColor;
title.font = [UIFont systemFontOfSize:16.0 weight:UIFontWeightBold];
[self.hierarchyStackView addArrangedSubview:title];
for (NSDictionary *node in nodes) {
NSString *nodeId = node[@"id"];
if (![nodeId isKindOfClass:NSString.class]) {
continue;
}
UIButton *button = [UIButton buttonWithType:UIButtonTypeSystem];
NSString *visible = [node[@"visible"] boolValue] ? @"●" : @"○";
[button setTitle:[NSString stringWithFormat:@"%@ %@", visible, node[@"label"] ?: nodeId] forState:UIControlStateNormal];
button.contentHorizontalAlignment = UIControlContentHorizontalAlignmentLeft;
button.accessibilityIdentifier = nodeId;
button.titleLabel.font = [UIFont monospacedSystemFontOfSize:13.0 weight:[nodeId isEqualToString:self.selectedNodeId] ? UIFontWeightBold : UIFontWeightRegular];
button.tintColor = [nodeId isEqualToString:self.selectedNodeId] ? [UIColor colorWithRed:0.55 green:0.85 blue:1.0 alpha:1.0] : [UIColor colorWithWhite:0.85 alpha:1.0];
[button addTarget:self action:@selector(hierarchyNodePressed:) forControlEvents:UIControlEventTouchUpInside];
[self.hierarchyStackView addArrangedSubview:button];
}
}
- (void)nodeTapped:(UITapGestureRecognizer *)recognizer {
NSString *nodeId = recognizer.view.accessibilityIdentifier;
if (!nodeId) {
return;
}
self.selectedNodeId = nodeId;
[self.dashboardClient sendCommand:@{@"type": @"select", @"nodeId": nodeId}];
}
- (void)nodePanned:(UIPanGestureRecognizer *)recognizer {
UIView *view = recognizer.view;
NSString *nodeId = view.accessibilityIdentifier;
if (!nodeId) {
return;
}
CGPoint translation = [recognizer translationInView:self.sceneCanvasView];
view.center = CGPointMake(view.center.x + translation.x, view.center.y + translation.y);
[recognizer setTranslation:CGPointZero inView:self.sceneCanvasView];
if (recognizer.state == UIGestureRecognizerStateChanged || recognizer.state == UIGestureRecognizerStateEnded) {
[self sendMoveForNodeId:nodeId center:view.center];
}
}
- (void)nodePinched:(UIPinchGestureRecognizer *)recognizer {
NSString *nodeId = recognizer.view.accessibilityIdentifier;
NSDictionary *node = nodeId ? self.dashboardNodes[nodeId] : nil;
if (!nodeId || !node) {
return;
}
if (recognizer.state == UIGestureRecognizerStateBegan) {
self.activeGestureStartScale = MAX(0.25, [node[@"scale"] doubleValue]);
}
CGFloat scale = MAX(0.25, MIN(3.0, self.activeGestureStartScale * recognizer.scale));
[self.dashboardClient sendCommand:@{@"type": @"scale", @"nodeId": nodeId, @"scale": @(scale)}];
}
- (void)nodeRotated:(UIRotationGestureRecognizer *)recognizer {
NSString *nodeId = recognizer.view.accessibilityIdentifier;
NSDictionary *node = nodeId ? self.dashboardNodes[nodeId] : nil;
if (!nodeId || !node) {
return;
}
if (recognizer.state == UIGestureRecognizerStateBegan) {
self.activeGestureStartRotation = [node[@"rotation"] doubleValue];
}
CGFloat rotation = self.activeGestureStartRotation + recognizer.rotation;
[self.dashboardClient sendCommand:@{@"type": @"rotate", @"nodeId": nodeId, @"rotation": @(rotation)}];
}
- (void)sendMoveForNodeId:(NSString *)nodeId center:(CGPoint)center {
CGFloat canvasWidth = MAX(1.0, self.sceneCanvasView.bounds.size.width);
CGFloat canvasHeight = MAX(1.0, self.sceneCanvasView.bounds.size.height);
CGFloat x = MIN(1.0, MAX(-1.0, (center.x / canvasWidth) * 2.0 - 1.0));
CGFloat y = MIN(1.0, MAX(-1.0, (center.y / canvasHeight) * 2.0 - 1.0));
[self.dashboardClient sendCommand:@{@"type": @"move", @"nodeId": nodeId, @"x": @(x), @"y": @(y)}];
}
- (void)hierarchyNodePressed:(UIButton *)button {
NSString *nodeId = button.accessibilityIdentifier;
if (!nodeId) {
return;
}
self.selectedNodeId = nodeId;
[self.dashboardClient sendCommand:@{@"type": @"select", @"nodeId": nodeId}];
}
- (void)toolbarButtonPressed:(UIButton *)button {
NSString *nodeId = self.selectedNodeId;
NSString *action = button.accessibilityIdentifier;
if (!nodeId || !action) {
return;
}
[self.dashboardClient sendCommand:@{@"type": action, @"nodeId": nodeId}];
}
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
(void)gestureRecognizer;
(void)otherGestureRecognizer;
return YES;
}
- (void)sendTouches:(NSSet<UITouch *> *)touches event:(UIEvent *)event phase:(NSString *)phase {
(void)event;
UITouch *touch = touches.anyObject;