Files
TZGyn 2d031e58a4 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.
2026-10-20 00:36:40 +08:00

171 lines
6.4 KiB
Swift

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))
}
}