Merge pull request #203 from Ebullioscopic/ui/fixes

Fix LottieAnimations
This commit is contained in:
Hariharan Mudaliar 2025-12-25 19:53:29 +05:30 • committed by GitHub
commit 8b06a7ec33
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 493 additions and 382 deletions

View file

@ -25,6 +25,7 @@
7E8840D72E9A346700C5CB8C /* Dog waiting.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E8840D62E9A346700C5CB8C /* Dog waiting.json */; }; 7E8840D72E9A346700C5CB8C /* Dog waiting.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E8840D62E9A346700C5CB8C /* Dog waiting.json */; };
7E8840D92E9A347000C5CB8C /* Moody Dog.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E8840D82E9A347000C5CB8C /* Moody Dog.json */; }; 7E8840D92E9A347000C5CB8C /* Moody Dog.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E8840D82E9A347000C5CB8C /* Moody Dog.json */; };
7E8840DB2E9A347800C5CB8C /* Orange Cat Peeping.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E8840DA2E9A347800C5CB8C /* Orange Cat Peeping.json */; }; 7E8840DB2E9A347800C5CB8C /* Orange Cat Peeping.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E8840DA2E9A347800C5CB8C /* Orange Cat Peeping.json */; };
7E9911B02EFD7D1B002EDFD5 /* Reindeer.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E9911AF2EFD7D1B002EDFD5 /* Reindeer.json */; };
7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */; }; 7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */; };
7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73E2E6B1E1200997219 /* IOKit.framework */; }; 7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73E2E6B1E1200997219 /* IOKit.framework */; };
BA90505C953246D0AB206451 /* MediaRemoteAdapter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */; }; BA90505C953246D0AB206451 /* MediaRemoteAdapter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */; };
@ -55,6 +56,7 @@
7E8840D62E9A346700C5CB8C /* Dog waiting.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "Dog waiting.json"; sourceTree = "<group>"; }; 7E8840D62E9A346700C5CB8C /* Dog waiting.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "Dog waiting.json"; sourceTree = "<group>"; };
7E8840D82E9A347000C5CB8C /* Moody Dog.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "Moody Dog.json"; sourceTree = "<group>"; }; 7E8840D82E9A347000C5CB8C /* Moody Dog.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "Moody Dog.json"; sourceTree = "<group>"; };
7E8840DA2E9A347800C5CB8C /* Orange Cat Peeping.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "Orange Cat Peeping.json"; sourceTree = "<group>"; }; 7E8840DA2E9A347800C5CB8C /* Orange Cat Peeping.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "Orange Cat Peeping.json"; sourceTree = "<group>"; };
7E9911AF2EFD7D1B002EDFD5 /* Reindeer.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Reindeer.json; sourceTree = "<group>"; };
7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ApplicationServices.framework; path = System/Library/Frameworks/ApplicationServices.framework; sourceTree = SDKROOT; }; 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ApplicationServices.framework; path = System/Library/Frameworks/ApplicationServices.framework; sourceTree = SDKROOT; };
7EE1D73E2E6B1E1200997219 /* IOKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IOKit.framework; path = System/Library/Frameworks/IOKit.framework; sourceTree = SDKROOT; }; 7EE1D73E2E6B1E1200997219 /* IOKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IOKit.framework; path = System/Library/Frameworks/IOKit.framework; sourceTree = SDKROOT; };
F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaRemoteAdapter.framework; path = Frameworks/MediaRemoteAdapter.framework; sourceTree = SOURCE_ROOT; }; F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaRemoteAdapter.framework; path = Frameworks/MediaRemoteAdapter.framework; sourceTree = SOURCE_ROOT; };
@ -122,6 +124,7 @@
children = ( children = (
7E6856472EA963FD00D261F5 /* Lock.json */, 7E6856472EA963FD00D261F5 /* Lock.json */,
7E8840D62E9A346700C5CB8C /* Dog waiting.json */, 7E8840D62E9A346700C5CB8C /* Dog waiting.json */,
7E9911AF2EFD7D1B002EDFD5 /* Reindeer.json */,
7E8840D82E9A347000C5CB8C /* Moody Dog.json */, 7E8840D82E9A347000C5CB8C /* Moody Dog.json */,
7E8840DA2E9A347800C5CB8C /* Orange Cat Peeping.json */, 7E8840DA2E9A347800C5CB8C /* Orange Cat Peeping.json */,
); );
@ -241,6 +244,7 @@
7E8130412E3FB40500E08F3F /* NowPlayingTestClient in Resources */, 7E8130412E3FB40500E08F3F /* NowPlayingTestClient in Resources */,
7E6856482EA963FD00D261F5 /* Lock.json in Resources */, 7E6856482EA963FD00D261F5 /* Lock.json in Resources */,
7E8840D92E9A347000C5CB8C /* Moody Dog.json in Resources */, 7E8840D92E9A347000C5CB8C /* Moody Dog.json in Resources */,
7E9911B02EFD7D1B002EDFD5 /* Reindeer.json in Resources */,
7E8840D72E9A346700C5CB8C /* Dog waiting.json in Resources */, 7E8840D72E9A346700C5CB8C /* Dog waiting.json in Resources */,
7E8840DB2E9A347800C5CB8C /* Orange Cat Peeping.json in Resources */, 7E8840DB2E9A347800C5CB8C /* Orange Cat Peeping.json in Resources */,
); );
@ -429,7 +433,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 14.6; MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = "1.2.1-beta"; MARKETING_VERSION = "1.2.2-beta";
PRODUCT_BUNDLE_IDENTIFIER = com.Ebullioscopic.Atoll.dev; PRODUCT_BUNDLE_IDENTIFIER = com.Ebullioscopic.Atoll.dev;
PRODUCT_NAME = Atoll; PRODUCT_NAME = Atoll;
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@ -500,7 +504,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 14.6; MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = "1.2.1-beta"; MARKETING_VERSION = "1.2.2-beta";
PRODUCT_BUNDLE_IDENTIFIER = com.Ebullioscopic.Atoll; PRODUCT_BUNDLE_IDENTIFIER = com.Ebullioscopic.Atoll;
PRODUCT_NAME = Atoll; PRODUCT_NAME = Atoll;
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";

View file

@ -1391,6 +1391,7 @@
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"1x" : { "1x" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"fr" : { "fr" : {
"stringUnit" : { "stringUnit" : {
@ -1471,6 +1472,7 @@
} }
}, },
"20" : { "20" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"fr" : { "fr" : {
"stringUnit" : { "stringUnit" : {
@ -1491,6 +1493,7 @@
} }
}, },
"30" : { "30" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"fr" : { "fr" : {
"stringUnit" : { "stringUnit" : {
@ -3050,6 +3053,7 @@
} }
}, },
"Animation" : { "Animation" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"fr" : { "fr" : {
"stringUnit" : { "stringUnit" : {
@ -3992,6 +3996,7 @@
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Blue dashed box shows actual size in Atoll" : { "Blue dashed box shows actual size in Atoll" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"fr" : { "fr" : {
"stringUnit" : { "stringUnit" : {
@ -4001,6 +4006,10 @@
} }
} }
}, },
"Blue outline matches the notch canvas" : {
"comment" : "A tooltip explaining that the blue outline in the animation preview matches the notch on a device.",
"isCommentAutoGenerated" : true
},
"Bluetooth Audio Devices" : { "Bluetooth Audio Devices" : {
"comment" : "A section header that lists Bluetooth audio devices.", "comment" : "A section header that lists Bluetooth audio devices.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@ -4140,9 +4149,6 @@
} }
} }
} }
},
"Bottom Pad:" : {
}, },
"Breakdown" : { "Breakdown" : {
"comment" : "A label displayed above the breakdown of memory usage.", "comment" : "A label displayed above the breakdown of memory usage.",
@ -4616,6 +4622,7 @@
} }
}, },
"Center" : { "Center" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@ -6004,6 +6011,10 @@
} }
} }
}, },
"Current Transform" : {
"comment" : "A label displayed above the current transform values in the preview stats view.",
"isCommentAutoGenerated" : true
},
"Currently active" : { "Currently active" : {
"comment" : "A text indicating whether the camera is \"Currently active\" or \"Not active\".", "comment" : "A text indicating whether the camera is \"Currently active\" or \"Not active\".",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@ -6601,6 +6612,10 @@
} }
} }
}, },
"Dash outline equals the exact notch bounds." : {
"comment" : "A description under the live preview section of the animation editor.",
"isCommentAutoGenerated" : true
},
"Default" : { "Default" : {
"localizations" : { "localizations" : {
"ar" : { "ar" : {
@ -7110,6 +7125,7 @@
} }
}, },
"Display Options" : { "Display Options" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"fr" : { "fr" : {
"stringUnit" : { "stringUnit" : {
@ -8234,6 +8250,9 @@
}, },
"Enable timer live activity" : { "Enable timer live activity" : {
},
"Enable when scaling beyond 30x20px so the Atoll opens gracefully." : {
}, },
"Engine Utilization" : { "Engine Utilization" : {
"comment" : "A label describing the utilization of the GPU's engine components.", "comment" : "A label describing the utilization of the GPU's engine components.",
@ -8678,7 +8697,7 @@
} }
} }
}, },
"Expand Notch with Animation" : { "Expand notch to follow animation width" : {
}, },
"Expanded drag detection area" : { "Expanded drag detection area" : {
@ -9686,6 +9705,7 @@
} }
}, },
"Height:" : { "Height:" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"fr" : { "fr" : {
"stringUnit" : { "stringUnit" : {
@ -10041,6 +10061,7 @@
} }
}, },
"Horizontal:" : { "Horizontal:" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"fr" : { "fr" : {
"stringUnit" : { "stringUnit" : {
@ -11057,9 +11078,6 @@
}, },
"Loop Mode" : { "Loop Mode" : {
},
"Loop Mode:" : {
}, },
"Lottie JSON URL" : { "Lottie JSON URL" : {
"localizations" : { "localizations" : {
@ -14952,9 +14970,6 @@
} }
} }
} }
},
"Opacity:" : {
}, },
"Open Google AI Studio" : { "Open Google AI Studio" : {
@ -15202,8 +15217,9 @@
} }
} }
}, },
"Preview Zoom:" : { "Preview Zoom" : {
"comment" : "A label describing the zoom control for the animation preview.",
"isCommentAutoGenerated" : true
}, },
"Privacy Indicators" : { "Privacy Indicators" : {
"comment" : "A heading for the section that lists privacy indicators.", "comment" : "A heading for the section that lists privacy indicators.",
@ -15336,9 +15352,6 @@
} }
} }
} }
},
"Quick Presets" : {
}, },
"Quick Share" : { "Quick Share" : {
"comment" : "A section header that says \"Quick Share\".", "comment" : "A section header that says \"Quick Share\".",
@ -16041,6 +16054,7 @@
}, },
"Rotation:" : { "Rotation:" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"fr" : { "fr" : {
"stringUnit" : { "stringUnit" : {
@ -16205,6 +16219,7 @@
} }
}, },
"Scale:" : { "Scale:" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"fr" : { "fr" : {
"stringUnit" : { "stringUnit" : {
@ -17614,6 +17629,7 @@
}, },
"Size & Scale" : { "Size & Scale" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"fr" : { "fr" : {
"stringUnit" : { "stringUnit" : {
@ -18149,6 +18165,7 @@
} }
}, },
"Speed:" : { "Speed:" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"fr" : { "fr" : {
"stringUnit" : { "stringUnit" : {
@ -19237,6 +19254,7 @@
} }
}, },
"Transform" : { "Transform" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"fr" : { "fr" : {
"stringUnit" : { "stringUnit" : {
@ -20194,6 +20212,7 @@
} }
}, },
"Vertical:" : { "Vertical:" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"fr" : { "fr" : {
"stringUnit" : { "stringUnit" : {
@ -20563,14 +20582,12 @@
}, },
"When enabled, stats monitoring stops a few seconds after the notch closes." : { "When enabled, stats monitoring stops a few seconds after the notch closes." : {
},
"When enabled, the Atoll will expand horizontally to match the animation size" : {
}, },
"When enabled, the Stats tab will display real-time system performance graphs. This feature requires system permissions and may use additional battery." : { "When enabled, the Stats tab will display real-time system performance graphs. This feature requires system permissions and may use additional battery." : {
}, },
"Width:" : { "Width:" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"fr" : { "fr" : {
"stringUnit" : { "stringUnit" : {

View file

@ -36,7 +36,7 @@ struct AnimationEditorView: View {
@State private var isImporting = false @State private var isImporting = false
// Preview state // Preview state
@State private var previewScale: CGFloat = 1.0 @State private var previewScale: CGFloat = 10.0
init(sourceURL: URL, isRemoteURL: Bool, animation: Binding<CustomIdleAnimation?>, existingAnimation: CustomIdleAnimation? = nil) { init(sourceURL: URL, isRemoteURL: Bool, animation: Binding<CustomIdleAnimation?>, existingAnimation: CustomIdleAnimation? = nil) {
self.sourceURL = sourceURL self.sourceURL = sourceURL
@ -69,62 +69,108 @@ struct AnimationEditorView: View {
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
// Header header
HStack { Divider()
HStack(spacing: 0) {
previewPanel
.frame(minWidth: 320, maxWidth: 360)
Divider()
controlsPanel
}
Divider()
footer
}
.frame(idealWidth: 980, idealHeight: 640)
.frame(minWidth: 860, minHeight: 620)
.fixedSize()
.alert("Import Error", isPresented: $showingError) {
Button("OK") { }
} message: {
Text(errorMessage)
}
}
// MARK: - Layout Sections
private var header: some View {
HStack(alignment: .center) {
VStack(alignment: .leading, spacing: 4) {
Text(existingAnimation != nil ? "Edit Animation" : "Customize Animation") Text(existingAnimation != nil ? "Edit Animation" : "Customize Animation")
.font(.title2) .font(.title2)
.fontWeight(.semibold) .fontWeight(.semibold)
Text(sourceDescription)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer() Spacer()
Button("Cancel") { Button("Cancel") {
dismiss() dismiss()
} }
.keyboardShortcut(.cancelAction) .keyboardShortcut(.cancelAction)
} }
.padding() .padding()
}
Divider() private var footer: some View {
HStack {
Label("Blue outline matches the notch canvas", systemImage: "info.circle")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Button(existingAnimation != nil ? "Save Changes" : "Import") {
importAnimation()
}
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
.disabled(name.isEmpty || isImporting)
}
.padding()
}
HSplitView { private var previewPanel: some View {
// Preview Panel VStack(alignment: .leading, spacing: 16) {
VStack { VStack(alignment: .leading, spacing: 4) {
Text("Preview") Text("Live Preview")
.font(.headline) .font(.headline)
.padding(.top) Text("Dash outline equals the exact notch bounds.")
.font(.caption)
.foregroundStyle(.secondary)
}
previewCanvas
previewZoomControls
previewStats
Spacer(minLength: 0)
}
.padding(20)
}
@ViewBuilder
private var previewCanvas: some View {
ZStack { ZStack {
// Background to show bounds RoundedRectangle(cornerRadius: 18, style: .continuous)
RoundedRectangle(cornerRadius: 8)
.fill(Color(nsColor: .controlBackgroundColor)) .fill(Color(nsColor: .controlBackgroundColor))
.overlay( .overlay(
RoundedRectangle(cornerRadius: 8) RoundedRectangle(cornerRadius: 18, style: .continuous)
.strokeBorder(Color.gray.opacity(0.3), lineWidth: 1) .stroke(Color.white.opacity(0.08), lineWidth: 1)
) )
.shadow(color: .black.opacity(0.15), radius: 16, x: 0, y: 8)
// Target size indicator (30x20 - actual size in notch)
Rectangle() Rectangle()
.strokeBorder(Color.blue.opacity(0.5), style: StrokeStyle(lineWidth: 2, dash: [5, 3])) .strokeBorder(Color.blue.opacity(0.55), style: StrokeStyle(lineWidth: 2, dash: [6, 3]))
.frame(width: 30 * previewScale, height: 20 * previewScale) .frame(width: 30 * previewScale, height: 20 * previewScale)
// Animation preview with transformations Group {
if let existing = existingAnimation, case .builtInFace = existing.source { if let existing = existingAnimation, case .builtInFace = existing.source {
// Show built-in face
MinimalFaceFeatures(height: 20, width: 30) MinimalFaceFeatures(height: 20, width: 30)
.scaleEffect(previewScale) .scaleEffect(previewScale)
.frame(width: cropWidth * scale * previewScale, height: cropHeight * scale * previewScale)
.offset(x: offsetX * previewScale, y: offsetY * previewScale)
.rotationEffect(.degrees(rotation))
.opacity(opacity)
.padding(.bottom, paddingBottom * previewScale)
.clipped()
} else { } else {
// Show Lottie animation
LottieView(state: LUStateData( LottieView(state: LUStateData(
type: .loadedFrom(sourceURL), type: .loadedFrom(sourceURL),
speed: speed, speed: speed,
loopMode: loopMode.lottieLoopMode loopMode: loopMode.lottieLoopMode
)) ))
}
}
.frame(width: cropWidth * scale * previewScale, height: cropHeight * scale * previewScale) .frame(width: cropWidth * scale * previewScale, height: cropHeight * scale * previewScale)
.offset(x: offsetX * previewScale, y: offsetY * previewScale) .offset(x: offsetX * previewScale, y: offsetY * previewScale)
.rotationEffect(.degrees(rotation)) .rotationEffect(.degrees(rotation))
@ -132,209 +178,96 @@ struct AnimationEditorView: View {
.padding(.bottom, paddingBottom * previewScale) .padding(.bottom, paddingBottom * previewScale)
.clipped() .clipped()
} }
.frame(maxWidth: .infinity)
.frame(height: 280)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
// Preview Scale Control private var previewZoomControls: some View {
VStack(alignment: .leading, spacing: 8) {
HStack { HStack {
Text("Preview Zoom:") Label("Preview Zoom", systemImage: "magnifyingglass")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Slider(value: $previewScale, in: 1...10) Spacer()
.frame(width: 150)
Text("\(Int(previewScale))x") Text("\(Int(previewScale))x")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(width: 30) .monospacedDigit()
} }
.padding(.horizontal) HStack(spacing: 10) {
.padding(.bottom) Button {
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
previewScale = max(1, previewScale - 1)
} }
.frame(minWidth: 300) } label: {
Image(systemName: "minus")
.frame(width: 28, height: 28)
}
.buttonStyle(.bordered)
.controlSize(.mini)
Divider() Slider(value: $previewScale, in: 1...10)
// Controls Panel Button {
ScrollView { withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
VStack(alignment: .leading, spacing: 20) { previewScale = min(10, previewScale + 1)
// Name }
} label: {
Image(systemName: "plus")
.frame(width: 28, height: 28)
}
.buttonStyle(.bordered)
.controlSize(.mini)
Button("Reset") {
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
previewScale = 10
}
}
.buttonStyle(.bordered)
.controlSize(.mini)
}
}
}
private var previewStats: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("Current Transform")
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 10) {
PreviewStatChip(title: "Scale", value: String(format: "%.2fx", scale))
PreviewStatChip(title: "Output", value: "\(Int(cropWidth * scale))x\(Int(cropHeight * scale)) px")
PreviewStatChip(title: "Offset", value: String(format: "%.1f / %.1f", offsetX, offsetY))
}
}
}
private var controlsPanel: some View {
ScrollView {
VStack(spacing: 16) {
EditorSectionCard(title: "Animation Details", subtitle: "Give it a friendly name and control playback speed.") {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 6) {
Text("Animation Name") Text("Animation Name")
.font(.headline) .font(.subheadline)
.fontWeight(.medium)
TextField("Name", text: $name) TextField("Name", text: $name)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
} }
ParameterSliderRow(
Divider() title: "Playback Speed",
value: $speed,
// Size Controls range: 0.1...3.0,
VStack(alignment: .leading, spacing: 12) { formatter: { String(format: "%.2fx", $0) },
Text("Size & Scale") resetLabel: "1x",
.font(.headline) resetValue: 1.0,
step: 0.05
// Scale )
HStack { VStack(alignment: .leading, spacing: 6) {
Text("Scale:") Text("Loop Mode")
.frame(width: 80, alignment: .leading)
Slider(value: $scale, in: 0.1...5.0)
Text(String(format: "%.2f", scale))
.frame(width: 50, alignment: .trailing)
.foregroundStyle(.secondary)
Button("Reset") {
withAnimation { scale = 1.0 }
}
.buttonStyle(.bordered)
.controlSize(.small)
}
// Crop Width
HStack {
Text("Width:")
.frame(width: 80, alignment: .leading)
Slider(value: $cropWidth, in: 5...100)
Text(String(format: "%.0f", cropWidth))
.frame(width: 50, alignment: .trailing)
.foregroundStyle(.secondary)
Button("30") {
withAnimation { cropWidth = 30 }
}
.buttonStyle(.bordered)
.controlSize(.small)
}
// Crop Height
HStack {
Text("Height:")
.frame(width: 80, alignment: .leading)
Slider(value: $cropHeight, in: 5...100)
Text(String(format: "%.0f", cropHeight))
.frame(width: 50, alignment: .trailing)
.foregroundStyle(.secondary)
Button("20") {
withAnimation { cropHeight = 20 }
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
Divider()
// Position Controls
VStack(alignment: .leading, spacing: 12) {
Text("Position")
.font(.headline)
// Offset X
HStack {
Text("Horizontal:")
.frame(width: 80, alignment: .leading)
Slider(value: $offsetX, in: -50...50)
Text(String(format: "%.1f", offsetX))
.frame(width: 50, alignment: .trailing)
.foregroundStyle(.secondary)
Button("Center") {
withAnimation { offsetX = 0 }
}
.buttonStyle(.bordered)
.controlSize(.small)
}
// Offset Y
HStack {
Text("Vertical:")
.frame(width: 80, alignment: .leading)
Slider(value: $offsetY, in: -50...50)
Text(String(format: "%.1f", offsetY))
.frame(width: 50, alignment: .trailing)
.foregroundStyle(.secondary)
Button("Center") {
withAnimation { offsetY = 0 }
}
.buttonStyle(.bordered)
.controlSize(.small)
}
// Padding Bottom
HStack {
Text("Bottom Pad:")
.frame(width: 80, alignment: .leading)
Slider(value: $paddingBottom, in: -20...20)
Text(String(format: "%.1f", paddingBottom))
.frame(width: 50, alignment: .trailing)
.foregroundStyle(.secondary)
Button("Reset") {
withAnimation { paddingBottom = 0 }
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
Divider()
// Rotation & Opacity
VStack(alignment: .leading, spacing: 12) {
Text("Transform")
.font(.headline)
// Rotation
HStack {
Text("Rotation:")
.frame(width: 80, alignment: .leading)
Slider(value: $rotation, in: -180...180)
Text(String(format: "%.0f°", rotation))
.frame(width: 50, alignment: .trailing)
.foregroundStyle(.secondary)
Button("Reset") {
withAnimation { rotation = 0 }
}
.buttonStyle(.bordered)
.controlSize(.small)
}
// Opacity
HStack {
Text("Opacity:")
.frame(width: 80, alignment: .leading)
Slider(value: $opacity, in: 0...1)
Text(String(format: "%.0f%%", opacity * 100))
.frame(width: 50, alignment: .trailing)
.foregroundStyle(.secondary)
Button("100%") {
withAnimation { opacity = 1.0 }
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
Divider()
// Animation Speed
VStack(alignment: .leading, spacing: 12) {
Text("Animation")
.font(.headline)
HStack {
Text("Speed:")
.frame(width: 80, alignment: .leading)
Slider(value: $speed, in: 0.1...3.0)
Text(String(format: "%.2fx", speed))
.frame(width: 50, alignment: .trailing)
.foregroundStyle(.secondary)
Button("1x") {
withAnimation { speed = 1.0 }
}
.buttonStyle(.bordered)
.controlSize(.small)
}
// Loop Mode
VStack(alignment: .leading, spacing: 8) {
Text("Loop Mode:")
.font(.subheadline) .font(.subheadline)
.fontWeight(.medium)
Picker("Loop Mode", selection: $loopMode) { Picker("Loop Mode", selection: $loopMode) {
ForEach(AnimationLoopMode.allCases, id: \.self) { mode in ForEach(AnimationLoopMode.allCases, id: \.self) { mode in
Text(mode.rawValue).tag(mode) Text(mode.rawValue).tag(mode)
@ -343,33 +276,101 @@ struct AnimationEditorView: View {
.pickerStyle(.segmented) .pickerStyle(.segmented)
} }
} }
}
Divider() EditorSectionCard(title: "Size & Crop", subtitle: "Match the notch footprint or intentionally exceed it.") {
VStack(spacing: 12) {
ParameterSliderRow(
title: "Scale",
value: $scale,
range: 0.1...5.0,
formatter: { String(format: "%.2fx", $0) },
resetLabel: "Reset",
resetValue: 1.0
)
ParameterSliderRow(
title: "Visible Width",
value: $cropWidth,
range: 5...100,
formatter: { "\(Int($0)) px" },
resetLabel: "30",
resetValue: 30
)
ParameterSliderRow(
title: "Visible Height",
value: $cropHeight,
range: 5...100,
formatter: { "\(Int($0)) px" },
resetLabel: "20",
resetValue: 20
)
}
}
// Display Options EditorSectionCard(title: "Position & Padding", subtitle: "Nudge the animation inside the notch.") {
VStack(alignment: .leading, spacing: 12) { VStack(spacing: 12) {
Text("Display Options") ParameterSliderRow(
.font(.headline) title: "Horizontal Offset",
value: $offsetX,
range: -50...50,
formatter: { String(format: "%.1f px", $0) },
resetLabel: "Center",
resetValue: 0
)
ParameterSliderRow(
title: "Vertical Offset",
value: $offsetY,
range: -50...50,
formatter: { String(format: "%.1f px", $0) },
resetLabel: "Center",
resetValue: 0
)
ParameterSliderRow(
title: "Bottom Padding",
value: $paddingBottom,
range: -20...20,
formatter: { String(format: "%.1f px", $0) },
resetLabel: "Reset",
resetValue: 0
)
}
}
EditorSectionCard(title: "Transform & Display", subtitle: "Dial in the final polish.") {
VStack(spacing: 12) {
ParameterSliderRow(
title: "Rotation",
value: $rotation,
range: -180...180,
formatter: { String(format: "%.0f°", $0) },
resetLabel: "Reset",
resetValue: 0,
step: 1
)
ParameterSliderRow(
title: "Opacity",
value: $opacity,
range: 0...1,
formatter: { "\(Int($0 * 100))%" },
resetLabel: "100%",
resetValue: 1,
step: 0.01
)
Toggle(isOn: $expandWithAnimation) { Toggle(isOn: $expandWithAnimation) {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Expand Notch with Animation") Text("Expand notch to follow animation width")
.font(.body) .fontWeight(.medium)
Text("When enabled, the Atoll will expand horizontally to match the animation size") Text("Enable when scaling beyond 30x20px so the Atoll opens gracefully.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
} }
}
Divider() EditorSectionCard(title: "Quick Presets", subtitle: "Start from a popular layout and tweak from there.") {
let columns = [GridItem(.adaptive(minimum: 160), spacing: 12)]
// Presets LazyVGrid(columns: columns, spacing: 12) {
VStack(alignment: .leading, spacing: 12) {
Text("Quick Presets")
.font(.headline)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
Button("Fit to Notch") { Button("Fit to Notch") {
withAnimation { withAnimation {
scale = 1.0 scale = 1.0
@ -431,42 +432,19 @@ struct AnimationEditorView: View {
} }
} }
} }
.padding() .padding(20)
} }
.frame(minWidth: 350, maxWidth: 450) .frame(minWidth: 500, maxWidth: .infinity, maxHeight: .infinity)
} }
Divider() private var sourceDescription: String {
if let existing = existingAnimation {
// Footer with actions return existing.name
HStack {
HStack(spacing: 4) {
Image(systemName: "info.circle")
.foregroundStyle(.secondary)
Text("Blue dashed box shows actual size in Atoll")
.font(.caption)
.foregroundStyle(.secondary)
} }
if isRemoteURL {
Spacer() return sourceURL.absoluteString
Button(existingAnimation != nil ? "Save Changes" : "Import") {
importAnimation()
}
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
.disabled(name.isEmpty || isImporting)
}
.padding()
}
.frame(idealWidth: 900, idealHeight: 650)
.frame(minWidth: 800, minHeight: 600)
.fixedSize()
.alert("Import Error", isPresented: $showingError) {
Button("OK") { }
} message: {
Text(errorMessage)
} }
return sourceURL.deletingPathExtension().lastPathComponent
} }
// MARK: - Import Logic // MARK: - Import Logic
@ -534,6 +512,117 @@ struct AnimationEditorView: View {
} }
} }
// MARK: - Supporting Views
private struct EditorSectionCard<Content: View>: View {
let title: String
let subtitle: String?
let content: Content
init(title: String, subtitle: String? = nil, @ViewBuilder content: () -> Content) {
self.title = title
self.subtitle = subtitle
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.headline)
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
}
content
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(Color(nsColor: .controlBackgroundColor))
)
.overlay(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.stroke(Color.white.opacity(0.05), lineWidth: 1)
)
}
}
private struct ParameterSliderRow: View {
let title: String
@Binding var value: CGFloat
let range: ClosedRange<CGFloat>
let formatter: (CGFloat) -> String
var resetLabel: String? = nil
var resetValue: CGFloat? = nil
var step: CGFloat? = nil
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(title)
.fontWeight(.medium)
Spacer()
Text(formatter(value))
.font(.caption)
.foregroundStyle(.secondary)
.monospacedDigit()
}
HStack(spacing: 10) {
slider
if let resetLabel, let resetValue {
Button(resetLabel) {
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
value = resetValue
}
}
.buttonStyle(.bordered)
.controlSize(.mini)
}
}
}
}
@ViewBuilder
private var slider: some View {
let binding = Binding<Double>(
get: { Double(value) },
set: { value = CGFloat($0) }
)
let bounds = Double(range.lowerBound)...Double(range.upperBound)
if let step {
Slider(value: binding, in: bounds, step: Double(step))
} else {
Slider(value: binding, in: bounds)
}
}
}
private struct PreviewStatChip: View {
let title: String
let value: String
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.caption2)
.foregroundStyle(.secondary)
Text(value)
.font(.caption)
.fontWeight(.semibold)
.monospacedDigit()
}
.padding(.vertical, 8)
.padding(.horizontal, 10)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.white.opacity(0.06))
)
}
}
// MARK: - Preview // MARK: - Preview
#Preview { #Preview {
AnimationEditorView( AnimationEditorView(

View file

@ -80,7 +80,7 @@ class IdleAnimationManager {
print("📦 [IdleAnimationManager] Loading bundled animations...") print("📦 [IdleAnimationManager] Loading bundled animations...")
// The JSON files are added as individual resources, not in a folder // The JSON files are added as individual resources, not in a folder
let bundledFiles = ["Dog waiting", "Moody Dog", "Orange Cat Peeping"] let bundledFiles = ["Dog waiting", "Moody Dog", "Orange Cat Peeping", "Reindeer"]
var animations: [CustomIdleAnimation] = [] var animations: [CustomIdleAnimation] = []
for filename in bundledFiles { for filename in bundledFiles {

File diff suppressed because one or more lines are too long