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,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