import AppKit import SwiftUI /// The wheel itself: a ring of output formats with the dragged file in the /// middle. Rendered into a transparent, non-activating overlay window. struct WheelView: View { @ObservedObject var model: WheelModel var body: some View { GeometryReader { proxy in let size = proxy.size let center = CGPoint(x: size.width / 2, y: size.height / 2) ZStack { if Settings.shared.dimBackground { Circle() .fill(Color.black.opacity(0.22)) .frame(width: model.radius * 3.4, height: model.radius * 3.4) .position(center) .blur(radius: 24) } disc(center: center) centerBadge(center: center) ForEach(Array(model.items.enumerated()), id: \.element.id) { index, item in itemView(item, isSelected: model.selection == index) .position(position(for: index, count: model.items.count, center: center)) } } } .frame(maxWidth: .infinity, maxHeight: .infinity) } // MARK: - Pieces private func disc(center: CGPoint) -> some View { let diameter = (model.radius + 46) * 2 return ZStack { Circle() .fill(.ultraThinMaterial) Circle() .strokeBorder(Color.white.opacity(0.14), lineWidth: 1) Circle() .strokeBorder(Color.white.opacity(0.06), lineWidth: 1) .frame(width: diameter - 26, height: diameter - 26) } .frame(width: diameter, height: diameter) .position(center) .shadow(color: .black.opacity(0.28), radius: 22, y: 6) } private func centerBadge(center: CGPoint) -> some View { VStack(spacing: 5) { Image(nsImage: model.icon) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 40, height: 40) Text(model.summary) .font(.system(size: 11, weight: .medium)) .lineLimit(1) .truncationMode(.middle) .frame(maxWidth: max(90, model.radius * 0.95)) if let item = model.selectedItem, !item.isEnabled, let reason = item.availability.reason { Text(reason) .font(.system(size: 9)) .foregroundStyle(.orange) .multilineTextAlignment(.center) .lineLimit(2) .frame(maxWidth: max(110, model.radius * 1.1)) } else { Text(model.subtitle) .font(.system(size: 9, weight: .semibold)) .foregroundStyle(.secondary) .textCase(.uppercase) } } .padding(10) .background( Circle() .fill(Color(nsColor: .windowBackgroundColor).opacity(0.55)) ) .position(center) } private func itemView(_ item: WheelItem, isSelected: Bool) -> some View { VStack(spacing: 4) { ZStack { Circle() .fill(isSelected ? Color.accentColor.opacity(0.92) : Color.black.opacity(0.34)) Circle() .strokeBorder(Color.white.opacity(isSelected ? 0.85 : 0.16), lineWidth: isSelected ? 1.5 : 1) Image(systemName: item.symbol) .font(.system(size: 19, weight: .semibold)) .foregroundStyle(.white) if !item.isEnabled { Image(systemName: "lock.fill") .font(.system(size: 9, weight: .bold)) .foregroundStyle(.white) .padding(3) .background(Circle().fill(Color.orange)) .offset(x: 16, y: -16) } } .frame(width: 46, height: 46) if Settings.shared.showLabels { Text(item.title) .font(.system(size: 10.5, weight: .semibold)) .foregroundStyle(.white) .lineLimit(1) .minimumScaleFactor(0.75) .shadow(color: .black.opacity(0.65), radius: 2, y: 1) } } .frame(width: 84) .opacity(item.isEnabled ? 1 : 0.62) .scaleEffect(isSelected ? 1.14 : 1) .animation(.spring(response: 0.18, dampingFraction: 0.8), value: isSelected) } private func position(for index: Int, count: Int, center: CGPoint) -> CGPoint { guard count > 0 else { return center } let step = 360.0 / Double(count) let degrees = 90.0 - Double(index) * step let radians = degrees * .pi / 180 return CGPoint( x: center.x + CGFloat(cos(radians)) * model.radius, y: center.y - CGFloat(sin(radians)) * model.radius ) } } /// Compact list rendering, for users who prefer a straight menu. struct WheelListView: View { @ObservedObject var model: WheelModel var body: some View { VStack(alignment: .leading, spacing: 2) { Text(model.summary) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(.secondary) .lineLimit(1) ForEach(Array(model.items.enumerated()), id: \.element.id) { index, item in HStack(spacing: 8) { Image(systemName: item.symbol) .font(.system(size: 11)) .frame(width: 16) Text(item.title) .font(.system(size: 12)) Spacer(minLength: 8) if !item.isEnabled { Image(systemName: "lock.fill").font(.system(size: 9)) } } .padding(.horizontal, 8) .padding(.vertical, 4) .background( RoundedRectangle(cornerRadius: 6) .fill(model.selection == index ? Color.accentColor.opacity(0.85) : Color.clear) ) .foregroundStyle(model.selection == index ? Color.white : Color.primary) } } .padding(10) .frame(width: 220) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) } }