Fix LottieAnimations

This commit is contained in:
Hariharan Mudaliar 2025-12-25 19:52:42 +05:30
commit 2657fffc41
No known key found for this signature in database
GPG key ID: 025583EB0BE5640E
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 */; };
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 */; };
7E9911B02EFD7D1B002EDFD5 /* Reindeer.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E9911AF2EFD7D1B002EDFD5 /* Reindeer.json */; };
7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */; };
7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73E2E6B1E1200997219 /* IOKit.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>"; };
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>"; };
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; };
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; };
@ -122,6 +124,7 @@
children = (
7E6856472EA963FD00D261F5 /* Lock.json */,
7E8840D62E9A346700C5CB8C /* Dog waiting.json */,
7E9911AF2EFD7D1B002EDFD5 /* Reindeer.json */,
7E8840D82E9A347000C5CB8C /* Moody Dog.json */,
7E8840DA2E9A347800C5CB8C /* Orange Cat Peeping.json */,
);
@ -241,6 +244,7 @@
7E8130412E3FB40500E08F3F /* NowPlayingTestClient in Resources */,
7E6856482EA963FD00D261F5 /* Lock.json in Resources */,
7E8840D92E9A347000C5CB8C /* Moody Dog.json in Resources */,
7E9911B02EFD7D1B002EDFD5 /* Reindeer.json in Resources */,
7E8840D72E9A346700C5CB8C /* Dog waiting.json in Resources */,
7E8840DB2E9A347800C5CB8C /* Orange Cat Peeping.json in Resources */,
);
@ -429,7 +433,7 @@
"@executable_path/../Frameworks",
);
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_NAME = Atoll;
PROVISIONING_PROFILE_SPECIFIER = "";
@ -500,7 +504,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = "1.2.1-beta";
MARKETING_VERSION = "1.2.2-beta";
PRODUCT_BUNDLE_IDENTIFIER = com.Ebullioscopic.Atoll;
PRODUCT_NAME = Atoll;
PROVISIONING_PROFILE_SPECIFIER = "";

View file

@ -1391,6 +1391,7 @@
"isCommentAutoGenerated" : true
},
"1x" : {
"extractionState" : "stale",
"localizations" : {
"fr" : {
"stringUnit" : {
@ -1471,6 +1472,7 @@
}
},
"20" : {
"extractionState" : "stale",
"localizations" : {
"fr" : {
"stringUnit" : {
@ -1491,6 +1493,7 @@
}
},
"30" : {
"extractionState" : "stale",
"localizations" : {
"fr" : {
"stringUnit" : {
@ -3050,6 +3053,7 @@
}
},
"Animation" : {
"extractionState" : "stale",
"localizations" : {
"fr" : {
"stringUnit" : {
@ -3992,6 +3996,7 @@
"isCommentAutoGenerated" : true
},
"Blue dashed box shows actual size in Atoll" : {
"extractionState" : "stale",
"localizations" : {
"fr" : {
"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" : {
"comment" : "A section header that lists Bluetooth audio devices.",
"isCommentAutoGenerated" : true,
@ -4140,9 +4149,6 @@
}
}
}
},
"Bottom Pad:" : {
},
"Breakdown" : {
"comment" : "A label displayed above the breakdown of memory usage.",
@ -4616,6 +4622,7 @@
}
},
"Center" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"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" : {
"comment" : "A text indicating whether the camera is \"Currently active\" or \"Not active\".",
"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" : {
"localizations" : {
"ar" : {
@ -7110,6 +7125,7 @@
}
},
"Display Options" : {
"extractionState" : "stale",
"localizations" : {
"fr" : {
"stringUnit" : {
@ -8234,6 +8250,9 @@
},
"Enable timer live activity" : {
},
"Enable when scaling beyond 30x20px so the Atoll opens gracefully." : {
},
"Engine Utilization" : {
"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" : {
@ -9686,6 +9705,7 @@
}
},
"Height:" : {
"extractionState" : "stale",
"localizations" : {
"fr" : {
"stringUnit" : {
@ -10041,6 +10061,7 @@
}
},
"Horizontal:" : {
"extractionState" : "stale",
"localizations" : {
"fr" : {
"stringUnit" : {
@ -11057,9 +11078,6 @@
},
"Loop Mode" : {
},
"Loop Mode:" : {
},
"Lottie JSON URL" : {
"localizations" : {
@ -14952,9 +14970,6 @@
}
}
}
},
"Opacity:" : {
},
"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" : {
"comment" : "A heading for the section that lists privacy indicators.",
@ -15336,9 +15352,6 @@
}
}
}
},
"Quick Presets" : {
},
"Quick Share" : {
"comment" : "A section header that says \"Quick Share\".",
@ -16041,6 +16054,7 @@
},
"Rotation:" : {
"extractionState" : "stale",
"localizations" : {
"fr" : {
"stringUnit" : {
@ -16205,6 +16219,7 @@
}
},
"Scale:" : {
"extractionState" : "stale",
"localizations" : {
"fr" : {
"stringUnit" : {
@ -17614,6 +17629,7 @@
},
"Size & Scale" : {
"extractionState" : "stale",
"localizations" : {
"fr" : {
"stringUnit" : {
@ -18149,6 +18165,7 @@
}
},
"Speed:" : {
"extractionState" : "stale",
"localizations" : {
"fr" : {
"stringUnit" : {
@ -19237,6 +19254,7 @@
}
},
"Transform" : {
"extractionState" : "stale",
"localizations" : {
"fr" : {
"stringUnit" : {
@ -20194,6 +20212,7 @@
}
},
"Vertical:" : {
"extractionState" : "stale",
"localizations" : {
"fr" : {
"stringUnit" : {
@ -20563,14 +20582,12 @@
},
"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." : {
},
"Width:" : {
"extractionState" : "stale",
"localizations" : {
"fr" : {
"stringUnit" : {

View file

@ -36,7 +36,7 @@ struct AnimationEditorView: View {
@State private var isImporting = false
// 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) {
self.sourceURL = sourceURL
@ -69,62 +69,108 @@ struct AnimationEditorView: View {
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
header
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")
.font(.title2)
.fontWeight(.semibold)
Text(sourceDescription)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button("Cancel") {
dismiss()
}
.keyboardShortcut(.cancelAction)
}
.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 {
// Preview Panel
VStack {
Text("Preview")
private var previewPanel: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
Text("Live Preview")
.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 {
// Background to show bounds
RoundedRectangle(cornerRadius: 8)
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(Color(nsColor: .controlBackgroundColor))
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color.gray.opacity(0.3), lineWidth: 1)
RoundedRectangle(cornerRadius: 18, style: .continuous)
.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()
.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)
// Animation preview with transformations
Group {
if let existing = existingAnimation, case .builtInFace = existing.source {
// Show built-in face
MinimalFaceFeatures(height: 20, width: 30)
.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 {
// Show Lottie animation
LottieView(state: LUStateData(
type: .loadedFrom(sourceURL),
speed: speed,
loopMode: loopMode.lottieLoopMode
))
}
}
.frame(width: cropWidth * scale * previewScale, height: cropHeight * scale * previewScale)
.offset(x: offsetX * previewScale, y: offsetY * previewScale)
.rotationEffect(.degrees(rotation))
@ -132,209 +178,96 @@ struct AnimationEditorView: View {
.padding(.bottom, paddingBottom * previewScale)
.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 {
Text("Preview Zoom:")
Label("Preview Zoom", systemImage: "magnifyingglass")
.font(.caption)
.foregroundStyle(.secondary)
Slider(value: $previewScale, in: 1...10)
.frame(width: 150)
Spacer()
Text("\(Int(previewScale))x")
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 30)
.monospacedDigit()
}
.padding(.horizontal)
.padding(.bottom)
HStack(spacing: 10) {
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
ScrollView {
VStack(alignment: .leading, spacing: 20) {
// Name
Button {
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
previewScale = min(10, previewScale + 1)
}
} 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) {
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")
.font(.headline)
.font(.subheadline)
.fontWeight(.medium)
TextField("Name", text: $name)
.textFieldStyle(.roundedBorder)
}
Divider()
// Size Controls
VStack(alignment: .leading, spacing: 12) {
Text("Size & Scale")
.font(.headline)
// Scale
HStack {
Text("Scale:")
.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:")
ParameterSliderRow(
title: "Playback Speed",
value: $speed,
range: 0.1...3.0,
formatter: { String(format: "%.2fx", $0) },
resetLabel: "1x",
resetValue: 1.0,
step: 0.05
)
VStack(alignment: .leading, spacing: 6) {
Text("Loop Mode")
.font(.subheadline)
.fontWeight(.medium)
Picker("Loop Mode", selection: $loopMode) {
ForEach(AnimationLoopMode.allCases, id: \.self) { mode in
Text(mode.rawValue).tag(mode)
@ -343,33 +276,101 @@ struct AnimationEditorView: View {
.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
VStack(alignment: .leading, spacing: 12) {
Text("Display Options")
.font(.headline)
EditorSectionCard(title: "Position & Padding", subtitle: "Nudge the animation inside the notch.") {
VStack(spacing: 12) {
ParameterSliderRow(
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) {
VStack(alignment: .leading, spacing: 4) {
Text("Expand Notch with Animation")
.font(.body)
Text("When enabled, the Atoll will expand horizontally to match the animation size")
Text("Expand notch to follow animation width")
.fontWeight(.medium)
Text("Enable when scaling beyond 30x20px so the Atoll opens gracefully.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
Divider()
// Presets
VStack(alignment: .leading, spacing: 12) {
Text("Quick Presets")
.font(.headline)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
EditorSectionCard(title: "Quick Presets", subtitle: "Start from a popular layout and tweak from there.") {
let columns = [GridItem(.adaptive(minimum: 160), spacing: 12)]
LazyVGrid(columns: columns, spacing: 12) {
Button("Fit to Notch") {
withAnimation {
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()
// Footer with actions
HStack {
HStack(spacing: 4) {
Image(systemName: "info.circle")
.foregroundStyle(.secondary)
Text("Blue dashed box shows actual size in Atoll")
.font(.caption)
.foregroundStyle(.secondary)
private var sourceDescription: String {
if let existing = existingAnimation {
return existing.name
}
Spacer()
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)
if isRemoteURL {
return sourceURL.absoluteString
}
return sourceURL.deletingPathExtension().lastPathComponent
}
// 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
#Preview {
AnimationEditorView(

View file

@ -80,7 +80,7 @@ class IdleAnimationManager {
print("📦 [IdleAnimationManager] Loading bundled animations...")
// 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] = []
for filename in bundledFiles {

File diff suppressed because one or more lines are too long