Add Konverter: a local-only menu bar file converter
Drag files in Finder, hold a modifier, and drop on a wheel of outputs. Conversions run entirely on-device through ImageIO, PDFKit, AVFoundation, the bundled CLI tools, and optional ffmpeg/LibreOffice. Includes a --self-test suite (41 checks) and a --convert CLI mode so the conversion matrix can be verified without the UI.
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
/// Watches for drags anywhere on the system and reports them. Mouse events do
|
||||
/// not require any privacy permission; key events (Escape, arrows) do, so the
|
||||
/// wheel stays fully usable without them.
|
||||
final class DragMonitor {
|
||||
static let shared = DragMonitor()
|
||||
|
||||
/// Called on every poll while a drag is in flight.
|
||||
var onTick: (([URL], CGPoint, NSEvent.ModifierFlags) -> Void)?
|
||||
/// Called once when a drag starts, with the files being dragged.
|
||||
var onBegan: (([URL]) -> Void)?
|
||||
/// Called when the drag finishes (drop or cancel).
|
||||
var onEnded: ((CGPoint) -> Void)?
|
||||
/// Called for keys pressed during a drag.
|
||||
var onKey: ((UInt16, NSEvent.ModifierFlags) -> Bool)?
|
||||
|
||||
private var globalMouse: Any?
|
||||
private var localMouse: Any?
|
||||
private var keyMonitor: Any?
|
||||
private var timer: Timer?
|
||||
private var isDragging = false
|
||||
private var urls: [URL] = []
|
||||
|
||||
private init() {}
|
||||
|
||||
func start() {
|
||||
guard globalMouse == nil else { return }
|
||||
|
||||
let mask: NSEvent.EventTypeMask = [.leftMouseDragged, .rightMouseDragged, .otherMouseDragged]
|
||||
globalMouse = NSEvent.addGlobalMonitorForEvents(matching: mask) { [weak self] event in
|
||||
self?.handleDrag(event)
|
||||
}
|
||||
// Drags that end up over our own window are delivered to us, so the
|
||||
// global monitor never sees them.
|
||||
localMouse = NSEvent.addLocalMonitorForEvents(matching: mask) { [weak self] event in
|
||||
self?.handleDrag(event)
|
||||
return event
|
||||
}
|
||||
|
||||
let upMask: NSEvent.EventTypeMask = [.leftMouseUp, .rightMouseUp, .otherMouseUp]
|
||||
NSEvent.addGlobalMonitorForEvents(matching: upMask) { [weak self] _ in
|
||||
self?.handleEnd()
|
||||
}
|
||||
NSEvent.addLocalMonitorForEvents(matching: upMask) { [weak self] event in
|
||||
self?.handleEnd()
|
||||
return event
|
||||
}
|
||||
|
||||
keyMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
||||
guard let self, self.isDragging else { return }
|
||||
_ = self.onKey?(event.keyCode, event.modifierFlags)
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
if let globalMouse { NSEvent.removeMonitor(globalMouse) }
|
||||
if let localMouse { NSEvent.removeMonitor(localMouse) }
|
||||
if let keyMonitor { NSEvent.removeMonitor(keyMonitor) }
|
||||
globalMouse = nil
|
||||
localMouse = nil
|
||||
keyMonitor = nil
|
||||
stopTimer()
|
||||
}
|
||||
|
||||
private func handleDrag(_ event: NSEvent) {
|
||||
if !isDragging {
|
||||
let dragged = DragMonitor.draggedFileURLs()
|
||||
guard !dragged.isEmpty else { return }
|
||||
isDragging = true
|
||||
urls = dragged
|
||||
onBegan?(dragged)
|
||||
startTimer()
|
||||
}
|
||||
}
|
||||
|
||||
private func handleEnd() {
|
||||
guard isDragging else { return }
|
||||
onEnded?(NSEvent.mouseLocation)
|
||||
isDragging = false
|
||||
urls = []
|
||||
stopTimer()
|
||||
}
|
||||
|
||||
private func startTimer() {
|
||||
stopTimer()
|
||||
let timer = Timer(timeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in
|
||||
guard let self, self.isDragging else { return }
|
||||
self.onTick?(self.urls, NSEvent.mouseLocation, NSEvent.modifierFlags)
|
||||
}
|
||||
// `.common` keeps it firing even if a tracking loop is running.
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
self.timer = timer
|
||||
}
|
||||
|
||||
private func stopTimer() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
/// The files currently on the global drag pasteboard. Finder, Mail and most
|
||||
/// apps publish real file URLs; apps that only promise a file are read too.
|
||||
static func draggedFileURLs() -> [URL] {
|
||||
let pasteboard = NSPasteboard(name: .drag)
|
||||
guard let types = pasteboard.types else { return [] }
|
||||
|
||||
let fileTypes: Set<NSPasteboard.PasteboardType> = [
|
||||
.fileURL,
|
||||
.filePromise,
|
||||
NSPasteboard.PasteboardType("NSFilenamesPboardType")
|
||||
]
|
||||
guard types.contains(where: { fileTypes.contains($0) }) else { return [] }
|
||||
|
||||
var urls: [URL] = []
|
||||
if let objects = pasteboard.readObjects(
|
||||
forClasses: [NSURL.self],
|
||||
options: [.urlReadingFileURLsOnly: true]
|
||||
) as? [URL] {
|
||||
urls = objects
|
||||
}
|
||||
if urls.isEmpty, let paths = pasteboard.propertyList(forType: NSPasteboard.PasteboardType("NSFilenamesPboardType")) as? [String] {
|
||||
urls = paths.map { URL(fileURLWithPath: $0) }
|
||||
}
|
||||
return urls.filter { FileManager.default.fileExists(atPath: $0.path) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import AppKit
|
||||
import Combine
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// State behind the wheel: what is offered, what is highlighted, and what the
|
||||
/// centre of the wheel says.
|
||||
final class WheelModel: ObservableObject {
|
||||
@Published var kind: WheelKind = .convert
|
||||
@Published var items: [WheelItem] = []
|
||||
@Published var selection: Int?
|
||||
@Published var summary: String = ""
|
||||
@Published var subtitle: String = ""
|
||||
@Published var icon: NSImage = NSImage()
|
||||
@Published var radius: CGFloat = 168
|
||||
|
||||
var selectedItem: WheelItem? {
|
||||
guard let selection, items.indices.contains(selection) else { return nil }
|
||||
return items[selection]
|
||||
}
|
||||
}
|
||||
|
||||
/// Accepts the drop. Being a real drag destination is what stops Finder from
|
||||
/// moving the file, and it is how the wheel learns about the drop without
|
||||
/// needing an Accessibility event tap.
|
||||
final class DropCatcher: NSView {
|
||||
var onMoved: ((CGPoint) -> Void)?
|
||||
var onExit: (() -> Void)?
|
||||
var onDrop: (() -> Bool)?
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
registerForDraggedTypes([.fileURL, .filePromise, NSPasteboard.PasteboardType("NSFilenamesPboardType")])
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { nil }
|
||||
|
||||
private func screenPoint(_ sender: NSDraggingInfo) -> CGPoint {
|
||||
guard let window else { return NSEvent.mouseLocation }
|
||||
let inWindow = sender.draggingLocation
|
||||
return window.convertPoint(toScreen: inWindow)
|
||||
}
|
||||
|
||||
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
|
||||
onMoved?(screenPoint(sender))
|
||||
return .generic
|
||||
}
|
||||
|
||||
override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation {
|
||||
onMoved?(screenPoint(sender))
|
||||
return .generic
|
||||
}
|
||||
|
||||
override func draggingExited(_ sender: NSDraggingInfo?) {
|
||||
onExit?()
|
||||
}
|
||||
|
||||
override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { true }
|
||||
|
||||
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
|
||||
onMoved?(screenPoint(sender))
|
||||
return onDrop?() ?? false
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the overlay window and turns drags into jobs.
|
||||
final class WheelController {
|
||||
static let shared = WheelController()
|
||||
|
||||
private let model = WheelModel()
|
||||
private var window: NSWindow?
|
||||
private var catcher: DropCatcher?
|
||||
private var urls: [URL] = []
|
||||
private var isShowing = false
|
||||
private var didHandleDrop = false
|
||||
private var lastLocation: CGPoint = .zero
|
||||
private var pendingDismiss: DispatchWorkItem?
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Drag lifecycle
|
||||
|
||||
func begin(urls: [URL]) {
|
||||
self.urls = urls
|
||||
didHandleDrop = false
|
||||
}
|
||||
|
||||
func tick(location: CGPoint, modifiers: NSEvent.ModifierFlags) {
|
||||
guard !urls.isEmpty else { return }
|
||||
guard let kind = WheelBuilder.kind(for: modifiers) else {
|
||||
// No trigger key held: stay out of the way until one is pressed.
|
||||
if !isShowing { model.selection = nil }
|
||||
return
|
||||
}
|
||||
let items = WheelBuilder.items(kind: kind, urls: urls)
|
||||
guard !items.isEmpty else {
|
||||
dismiss()
|
||||
return
|
||||
}
|
||||
if !isShowing || model.kind != kind || model.items.map(\.id) != items.map(\.id) {
|
||||
present(kind: kind, items: items, at: location)
|
||||
} else {
|
||||
move(to: location)
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop committed by the drag destination.
|
||||
@discardableResult
|
||||
func commitDrop() -> Bool {
|
||||
guard let item = model.selectedItem else {
|
||||
dismiss()
|
||||
return false
|
||||
}
|
||||
guard item.isEnabled else {
|
||||
let reason = item.availability.reason ?? "Unavailable"
|
||||
DispatchQueue.main.async {
|
||||
Notifier.error(item.title, reason)
|
||||
}
|
||||
dismiss()
|
||||
return true
|
||||
}
|
||||
didHandleDrop = true
|
||||
let captured = urls
|
||||
let origin = lastLocation
|
||||
dismiss(animated: false)
|
||||
WheelController.dispatch(item: item, urls: captured, origin: origin)
|
||||
return true
|
||||
}
|
||||
|
||||
func end() {
|
||||
guard isShowing else { return }
|
||||
// Give the drag destination a beat to deliver the drop first.
|
||||
let work = DispatchWorkItem { [weak self] in
|
||||
guard let self else { return }
|
||||
if self.didHandleDrop { return }
|
||||
if let item = self.model.selectedItem, item.isEnabled {
|
||||
let captured = self.urls
|
||||
let origin = self.lastLocation
|
||||
self.dismiss(animated: false)
|
||||
WheelController.dispatch(item: item, urls: captured, origin: origin)
|
||||
} else {
|
||||
self.dismiss()
|
||||
}
|
||||
}
|
||||
pendingDismiss = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.22, execute: work)
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
pendingDismiss?.cancel()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
func handleKey(_ keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> Bool {
|
||||
guard isShowing else { return false }
|
||||
switch keyCode {
|
||||
case 53: // Escape
|
||||
cancel()
|
||||
return true
|
||||
case 123, 126: // Left / Up
|
||||
step(-1)
|
||||
return true
|
||||
case 124, 125: // Right / Down
|
||||
step(1)
|
||||
return true
|
||||
case 36, 76: // Return / Enter
|
||||
commitDrop()
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func step(_ delta: Int) {
|
||||
guard !model.items.isEmpty else { return }
|
||||
let count = model.items.count
|
||||
let current = model.selection ?? 0
|
||||
model.selection = ((current + delta) % count + count) % count
|
||||
}
|
||||
|
||||
// MARK: - Window
|
||||
|
||||
private func present(kind: WheelKind, items: [WheelItem], at location: CGPoint) {
|
||||
pendingDismiss?.cancel()
|
||||
|
||||
let radius = Self.radius(forItemCount: items.count)
|
||||
let itemExtent: CGFloat = 86
|
||||
let size = (radius + itemExtent) * 2
|
||||
|
||||
model.kind = kind
|
||||
model.items = items
|
||||
model.radius = radius
|
||||
model.summary = WheelBuilder.summary(for: urls)
|
||||
model.subtitle = kind.title
|
||||
model.icon = WheelBuilder.icon(for: urls)
|
||||
|
||||
let frame = CGRect(
|
||||
x: location.x - size / 2,
|
||||
y: location.y - size / 2,
|
||||
width: size,
|
||||
height: size
|
||||
)
|
||||
|
||||
let window: NSWindow
|
||||
if let existing = self.window {
|
||||
window = existing
|
||||
} else {
|
||||
let created = OverlayWindow(
|
||||
contentRect: frame,
|
||||
styleMask: [.borderless],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
created.isOpaque = false
|
||||
created.backgroundColor = .clear
|
||||
created.hasShadow = false
|
||||
created.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.screenSaverWindow)) - 1)
|
||||
created.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle]
|
||||
created.ignoresMouseEvents = false
|
||||
created.isMovable = false
|
||||
created.animationBehavior = .none
|
||||
|
||||
let catcher = DropCatcher(frame: CGRect(origin: .zero, size: CGSize(width: size, height: size)))
|
||||
catcher.autoresizingMask = [.width, .height]
|
||||
catcher.wantsLayer = true
|
||||
let hosting = NSHostingView(rootView: WheelView(model: model))
|
||||
hosting.frame = catcher.bounds
|
||||
hosting.autoresizingMask = [.width, .height]
|
||||
catcher.addSubview(hosting)
|
||||
|
||||
catcher.onMoved = { [weak self] point in
|
||||
self?.move(to: point)
|
||||
}
|
||||
catcher.onExit = { [weak self] in
|
||||
self?.model.selection = nil
|
||||
}
|
||||
catcher.onDrop = { [weak self] in
|
||||
self?.commitDrop() ?? false
|
||||
}
|
||||
|
||||
created.contentView = catcher
|
||||
self.catcher = catcher
|
||||
self.window = created
|
||||
window = created
|
||||
}
|
||||
|
||||
if !isShowing {
|
||||
window.alphaValue = 0
|
||||
window.orderFrontRegardless()
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.12
|
||||
window.animator().alphaValue = 1
|
||||
}
|
||||
} else {
|
||||
window.setFrame(frame, display: true)
|
||||
}
|
||||
isShowing = true
|
||||
move(to: location)
|
||||
}
|
||||
|
||||
private func move(to location: CGPoint) {
|
||||
guard let window else { return }
|
||||
lastLocation = location
|
||||
var frame = window.frame
|
||||
frame.origin = CGPoint(x: location.x - frame.width / 2, y: location.y - frame.height / 2)
|
||||
window.setFrame(frame, display: true)
|
||||
model.selection = Self.selectionIndex(for: location, in: frame, items: model.items, radius: model.radius)
|
||||
}
|
||||
|
||||
private func dismiss(animated: Bool = true) {
|
||||
pendingDismiss?.cancel()
|
||||
guard let window, isShowing else {
|
||||
isShowing = false
|
||||
return
|
||||
}
|
||||
isShowing = false
|
||||
model.selection = nil
|
||||
if animated {
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.1
|
||||
window.animator().alphaValue = 0
|
||||
} completionHandler: {
|
||||
window.orderOut(nil)
|
||||
}
|
||||
} else {
|
||||
window.orderOut(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
urls = []
|
||||
dismiss(animated: false)
|
||||
}
|
||||
|
||||
// MARK: - Geometry
|
||||
|
||||
/// Items are spread on a ring; the radius grows with the item count so the
|
||||
/// pills never touch.
|
||||
static func radius(forItemCount count: Int) -> CGFloat {
|
||||
let base = Settings.shared.wheelRadius
|
||||
guard count > 2 else { return base }
|
||||
let arc = CGFloat(count) * 84 / (2 * .pi)
|
||||
return max(base, arc + 40)
|
||||
}
|
||||
|
||||
/// Nearest item to the pointer, or nil when the pointer is off the ring or
|
||||
/// in the dead zone at the centre.
|
||||
static func selectionIndex(for location: CGPoint, in frame: CGRect, items: [WheelItem], radius: CGFloat) -> Int? {
|
||||
guard !items.isEmpty else { return nil }
|
||||
let center = CGPoint(x: frame.midX, y: frame.midY)
|
||||
let dx = location.x - center.x
|
||||
let dy = location.y - center.y
|
||||
let distance = (dx * dx + dy * dy).squareRoot()
|
||||
|
||||
guard distance > radius * 0.34 else { return nil }
|
||||
if items.count == 1 { return 0 }
|
||||
guard distance < radius * 1.75 else { return nil }
|
||||
|
||||
var angle = atan2(dy, dx) * 180 / .pi
|
||||
if angle < 0 { angle += 360 }
|
||||
let step = 360 / Double(items.count)
|
||||
let firstItemAngle = 90.0
|
||||
var offset = (firstItemAngle - angle).truncatingRemainder(dividingBy: 360)
|
||||
if offset < 0 { offset += 360 }
|
||||
let index = Int((offset / step).rounded()) % items.count
|
||||
return index
|
||||
}
|
||||
|
||||
// MARK: - Dispatch
|
||||
|
||||
private static func dispatch(item: WheelItem, urls: [URL], origin: CGPoint?) {
|
||||
switch item.payload {
|
||||
case .format(let format):
|
||||
JobStore.shared.submit(urls: urls, target: .format(format), origin: origin)
|
||||
|
||||
case .tool(let tool):
|
||||
if tool.fields.isEmpty {
|
||||
JobStore.shared.submit(urls: urls, target: .tool(tool), origin: origin)
|
||||
} else {
|
||||
OptionsWindowController.shared.present(tool: tool, urls: urls, origin: origin)
|
||||
}
|
||||
|
||||
case .action(let action):
|
||||
JobStore.shared.submit(urls: urls, target: .action(action), origin: origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A window that never steals focus and never becomes the key window, so the
|
||||
/// user's drag and the frontmost app keep working.
|
||||
final class OverlayWindow: NSWindow {
|
||||
override var canBecomeKey: Bool { false }
|
||||
override var canBecomeMain: Bool { false }
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user