Files
OSGKeyboard/OSGKeyboardMac/MacComponents.swift
T
2026-08-25 13:42:00 +08:00

685 lines
26 KiB
Swift

// MacComponents.swift
// OSGKeyboard · Mac
//
// Reusable macOS UI pieces styled with the shared design tokens and the
// system-native palette (see MacTheme.swift). Kept as plain SwiftUI (no
// AppKit) so they can be reused on iPadOS. Settings / History / Dictionary
// now use the native grouped `Form`, so the old hand-rolled card/row types
// were removed — what remains here is used by the Dashboard, the status
// footer and the menu-bar popover.
import SwiftUI
// MARK: - Shared layout metrics
/// Fixed metrics that keep every desktop surface on the same grid.
enum MacMetrics {
/// Shared height for search fields and primary actions in page headers.
static let pageHeaderControlHeight: CGFloat = 28
/// Shared height for credential inputs and icon buttons — matches the iOS
/// settings controls (38).
static let settingsControlHeight: CGFloat = 38
/// Fixed label column for provider rows: wider than iOS (150) so the label
/// text takes a larger share of the row and the trailing control narrows.
static let settingLabelWidth: CGFloat = 200
/// Uniform minimum height for every settings row, so rows read as an even
/// list regardless of whether they hold a 38pt control or a single-line
/// label. Content is centered within it; taller rows (status text, progress)
/// grow past it.
static let settingsRowMinHeight: CGFloat = 40
/// The single vertical rhythm for settings cards: the gap *between* rows AND
/// the padding between a card's top/bottom edge and its first/last row are
/// both this value, so the card breathes evenly. Applied as `VStack(spacing:)`
/// between rows and `.padding(.vertical:)` on the card body.
static let settingsRowGap: CGFloat = Spacing.md
/// Text-field max width for provider credential rows (narrower than the
/// old 360pt so long keys truncate instead of wrapping when the window shrinks).
static let fieldWidth: CGFloat = 280
/// Horizontal inset inside settings cards — matches History / Dictionary rows.
static let settingsCardInset: CGFloat = Spacing.md
/// Below this row width, provider rows stack label above control.
static let settingsCompactBreakpoint: CGFloat = 520
/// Sidebar width and the horizontal inset shared by brand, nav and footer.
static let sidebarWidth: CGFloat = 240
/// Horizontal inset for sidebar chrome (nav rows, footer). The brand logo
/// adds `Spacing.sm` on top of this so its left edge lines up with the
/// SF Symbol in each nav `Label`.
static let sidebarInset: CGFloat = Spacing.md
static let sidebarContentInset: CGFloat = sidebarInset + Spacing.sm
/// Reading width for single-column content.
static let contentMaxWidth: CGFloat = 720
/// Horizontal inset for page titles and scroll *content* (cards).
/// ScrollViews / Forms stay full-bleed so the scrollbar sits on the
/// window edge; only the content inside is inset.
/// Doubled from `Spacing.lg` so title + cards breathe from the edges.
static let pageHorizontalInset: CGFloat = Spacing.lg * 2
/// Minimum polish-style card width: at the default window the detail
/// pane (~540pt after sidebar + insets) fits three columns; narrowing
/// drops to two, widening adds a fourth+.
static let polishStyleCardMinWidth: CGFloat = 170
/// Built-in horizontal inset macOS grouped `Form` adds around its section
/// cards, on top of any padding we apply. Subtracted from
/// `pageHorizontalInset` on the Settings Form so its card outer edge lands
/// on `pageHorizontalInset` — matching the History page and the page title.
static let groupedFormSectionInset: CGFloat = Spacing.lg
/// Default (= minimum) main-window size. Opening the app uses this size;
/// the window cannot shrink below it.
static let windowMinWidth: CGFloat = 860
static let windowMinHeight: CGFloat = 600
/// Compact dictation-canvas height so Home fits the min window without scrolling.
static let dictationCanvasMinHeight: CGFloat = 120
/// Top inset that clears the window traffic-light buttons now that the
/// title bar is hidden.
static let trafficLightInset: CGFloat = 28
}
// MARK: - Settings typography (mirrors the iOS settings rows)
/// Type scale that maps 1:1 to the iOS settings controls so the desktop and
/// phone read identically: row label / picker value at body (15), hints at
/// caption2 (11), section header at caption2 (uppercased at the call site).
enum MacSettingsType {
static let sectionTitle = TypeStyle.caption2
static let rowLabel = TypeStyle.body
static let control = TypeStyle.body
static let controlEmph = TypeStyle.bodyEmph
static let hint = TypeStyle.caption2
static let button = TypeStyle.body
}
// MARK: - iOS-style toggle
/// Pill switch mirroring the iOS settings toggle: accent track when on, neutral
/// when off, 16pt white knob, spring slide. Colours resolve through the active
/// OSG palette so it follows the app theme.
struct MacToggleStyle: ToggleStyle {
@Environment(\.themePalette) private var palette
@Environment(\.colorScheme) private var colorScheme
func makeBody(configuration: Configuration) -> some View {
Button {
configuration.isOn.toggle()
} label: {
ZStack(alignment: configuration.isOn ? .trailing : .leading) {
Capsule()
.fill(configuration.isOn ? palette.accent : offTrackColor)
Circle()
.fill(Color.white)
.frame(width: 16, height: 16)
.shadow(color: .black.opacity(0.25), radius: 1, y: 1)
.padding(2)
}
.frame(width: 36, height: 20)
.animation(Motion.quick, value: configuration.isOn)
}
.buttonStyle(.plain)
}
private var offTrackColor: Color {
colorScheme == .dark
? Color.white.opacity(0.18)
: Color.black.opacity(0.15)
}
}
// MARK: - Liquid Glass
private struct MacGlassSurface<S: Shape>: ViewModifier {
@Environment(\.themePalette) private var palette
let shape: S
let fillOpacity: Double
func body(content: Content) -> some View {
// Flat, shadowless surface fill. We deliberately avoid `glassEffect`
// here: on macOS 26 Liquid Glass adds a raised drop shadow to every
// card, which reads as visual noise for content containers. Hierarchy
// is carried by the semantic surface colour instead.
content
.background(palette.surface.opacity(fillOpacity), in: shape)
}
}
extension View {
/// Applies a flat semantic surface fill (no drop shadow) behind `content`.
func macGlassSurface<S: Shape>(
in shape: S,
fillOpacity: Double = 0.72
) -> some View {
modifier(MacGlassSurface(shape: shape, fillOpacity: fillOpacity))
}
}
// MARK: - Text field styling
/// Theme-aware credential field chrome — comfortable tap target,
/// monospaced-friendly body size, and a fill/border that stays clearly
/// distinct from the section card in both appearances.
private struct MacFieldStyleModifier: ViewModifier {
@Environment(\.themePalette) private var palette
@Environment(\.colorScheme) private var colorScheme
var monospaced: Bool = false
func body(content: Content) -> some View {
// Mirrors the iOS credential field: recessed surface-elevated fill,
// medium radius, hairline border, comfortable 38pt tap target.
let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
content
.textFieldStyle(.plain)
.font(monospaced ? TypeStyle.mono : TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.padding(.horizontal, Spacing.sm)
.frame(minHeight: MacMetrics.settingsControlHeight)
.background(fieldFill, in: shape)
.overlay(shape.stroke(palette.divider, lineWidth: 0.5))
}
/// Light mode: the section card reads near-white, so a recessed `surfaceElevated`
/// grey barely differs. Use the system text-input background (true white) so the
/// field reads as an editable well. Dark mode keeps the elevated grey, which sits
/// *lighter* than the card and already stands out.
private var fieldFill: Color {
#if os(macOS)
if colorScheme != .dark {
return Color(nsColor: .textBackgroundColor)
}
#endif
return palette.surfaceElevated
}
}
extension View {
/// Theme-aware text-field styling for the settings inputs.
func macFieldStyle(monospaced: Bool = false) -> some View {
modifier(MacFieldStyleModifier(monospaced: monospaced))
}
}
// MARK: - Provider settings chrome
/// Section header + `MacCard` shell — same outer alignment as History /
/// Dictionary (title and card share `pageHorizontalInset` from the parent).
struct MacSettingsSection<Content: View>: View {
@Environment(\.themePalette) private var palette
let title: String
var footer: String?
@ViewBuilder var content: () -> Content
var body: some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
Text(title)
.font(MacSettingsType.sectionTitle)
.foregroundStyle(palette.textSecondary)
.textCase(.uppercase)
MacCard(padding: 0) {
VStack(alignment: .leading, spacing: 0) {
content()
}
// Card top/bottom breathe on the same rhythm as the gaps between
// rows (rows own their own inter-row spacing via their container).
.padding(.vertical, MacMetrics.settingsRowGap)
.frame(maxWidth: .infinity, alignment: .leading)
}
if let footer, !footer.isEmpty {
Text(footer)
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
/// iOS-style inline row: title (+ optional subtitle) on the left, control
/// right-aligned. Used by simple picker / toggle rows.
struct MacInlineRow<Control: View>: View {
@Environment(\.themePalette) private var palette
let title: String
var subtitle: String?
@ViewBuilder var control: () -> Control
var body: some View {
HStack(alignment: .center, spacing: Spacing.md) {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text(title)
.font(MacSettingsType.rowLabel)
.foregroundStyle(palette.textPrimary)
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(MacSettingsType.hint)
.foregroundStyle(palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
}
Spacer(minLength: Spacing.sm)
control()
}
.padding(.horizontal, MacMetrics.settingsCardInset)
.frame(minHeight: MacMetrics.settingsRowMinHeight)
}
}
/// Tappable navigation / action row inside a settings card.
struct MacFormLinkRow: View {
@Environment(\.themePalette) private var palette
let title: String
var showsChevron: Bool = true
var body: some View {
HStack(spacing: Spacing.sm) {
Text(title)
.font(MacSettingsType.rowLabel)
.foregroundStyle(palette.textPrimary)
Spacer(minLength: 0)
if showsChevron {
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
}
.padding(.horizontal, MacMetrics.settingsCardInset)
.frame(minHeight: MacMetrics.settingsRowMinHeight)
.contentShape(Rectangle())
}
}
/// Single merged card body for LLM / ASR provider configuration sections.
struct MacSettingsProviderCard<Content: View>: View {
@ViewBuilder var content: () -> Content
var body: some View {
VStack(spacing: MacMetrics.settingsRowGap) {
content()
}
}
}
// MARK: - Responsive provider setting row
/// Label + control row for provider cards. Uses a two-column layout at
/// comfortable widths and stacks vertically when horizontal space is tight.
struct MacProviderSettingRow<Content: View>: View {
@Environment(\.themePalette) private var palette
let title: String
var subtitle: String?
/// Cross-axis alignment between the label column and the control. Provider
/// rows keep `.top` (model row grows a status line below its field); single
/// control rows can pass `.center` to vertically center label and control.
var verticalAlignment: VerticalAlignment = .top
@ViewBuilder var content: () -> Content
var body: some View {
HStack(alignment: verticalAlignment, spacing: Spacing.md) {
label
.frame(width: MacMetrics.settingLabelWidth, alignment: .leading)
content()
.frame(maxWidth: .infinity, alignment: .leading)
}
// Uniform row height (content centered); inter-row spacing is owned by the
// enclosing card, so the row adds no vertical padding of its own.
.frame(maxWidth: .infinity, minHeight: MacMetrics.settingsRowMinHeight, alignment: .leading)
.padding(.horizontal, MacMetrics.settingsCardInset)
}
private var label: some View {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text(title)
.font(MacSettingsType.rowLabel)
.foregroundStyle(palette.textPrimary)
.fixedSize(horizontal: false, vertical: true)
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(MacSettingsType.hint)
.foregroundStyle(palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
}
// Align the title with the first 38pt control row, not with any
// status/help text that appears below the control.
.frame(minHeight: MacMetrics.settingsControlHeight, alignment: .leading)
}
}
/// Square icon button matching credential field height.
struct MacSettingsIconButton: View {
@Environment(\.themePalette) private var palette
let systemName: String
var help: String?
var disabled: Bool = false
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: systemName)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(disabled ? palette.textTertiary : palette.textSecondary)
.frame(width: MacMetrics.settingsControlHeight, height: MacMetrics.settingsControlHeight)
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
}
.buttonStyle(.plain)
.disabled(disabled)
.help(help ?? "")
}
}
/// Compact tool button for validate / fetch-models actions.
struct MacSettingsToolButton: View {
@Environment(\.themePalette) private var palette
let title: String
/// Background fill. Defaults to the neutral elevated surface.
var fill: Color?
/// Text color. Defaults to primary text (tertiary when disabled).
var foreground: Color?
var disabled: Bool = false
let action: () -> Void
var body: some View {
let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
Button(action: action) {
Text(title)
.font(MacSettingsType.button)
.foregroundStyle(resolvedForeground)
.padding(.horizontal, Spacing.md)
.frame(minHeight: 34)
.background(fill ?? palette.surfaceElevated, in: shape)
}
.buttonStyle(.plain)
.disabled(disabled)
}
private var resolvedForeground: Color {
if disabled { return palette.textTertiary }
return foreground ?? palette.textPrimary
}
}
// MARK: - Page header
/// Capsule-shaped primary action aligned with page-header search controls.
struct MacHeaderActionButtonStyle: ButtonStyle {
@Environment(\.themePalette) private var palette
@Environment(\.isEnabled) private var isEnabled
func makeBody(configuration: Configuration) -> some View {
configuration.label
.padding(.horizontal, Spacing.md)
.frame(height: MacMetrics.pageHeaderControlHeight)
.foregroundStyle(.white)
.background(
palette.accent.opacity(configuration.isPressed ? 0.82 : 1),
in: Capsule()
)
.contentShape(Capsule())
.opacity(isEnabled ? 1 : 0.45)
}
}
/// Page title for History / Dictionary / Settings. Applies the shared
/// `pageHorizontalInset` so its left edge matches inset card content below.
/// Type size matches Home's brand line (`TypeStyle.pageTitle`).
struct MacPageHeader<Trailing: View>: View {
@Environment(\.themePalette) private var palette
let title: String
var subtitle: String?
@ViewBuilder var trailing: () -> Trailing
init(
title: String,
subtitle: String? = nil,
@ViewBuilder trailing: @escaping () -> Trailing
) {
self.title = title
self.subtitle = subtitle
self.trailing = trailing
}
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: Spacing.md) {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text(title)
.font(TypeStyle.pageTitle)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.85)
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(TypeStyle.footnote)
.foregroundStyle(palette.textTertiary)
}
}
Spacer(minLength: 0)
trailing()
}
.padding(.horizontal, MacMetrics.pageHorizontalInset)
.padding(.top, Spacing.sm)
.padding(.bottom, Spacing.sm)
}
}
extension MacPageHeader where Trailing == EmptyView {
init(title: String, subtitle: String? = nil) {
self.init(title: title, subtitle: subtitle) { EmptyView() }
}
}
// MARK: - Card container
/// Elevated surface used for stat tiles and the dictation canvas.
struct MacCard<Content: View>: View {
var padding: CGFloat = Spacing.md
var cornerRadius: CGFloat = Radius.medium
@ViewBuilder var content: () -> Content
var body: some View {
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
content()
.padding(padding)
.macGlassSurface(in: shape, fillOpacity: 1)
}
}
// MARK: - Live waveform
/// Compact bar visualiser reacting to the input level while recording.
struct MiniWaveform: View {
@Environment(\.themePalette) private var palette
let level: Float
var barCount: Int = 5
/// Pass nil to inherit the palette accent automatically.
var tint: Color?
/// Peak bar height; overlay HUD uses a taller meter than the mic button.
var maxBarHeight: CGFloat = 22
var barWidth: CGFloat = 3
var barSpacing: CGFloat = 3
@State private var phase: CGFloat = 0
var body: some View {
HStack(spacing: barSpacing) {
ForEach(0..<barCount, id: \.self) { index in
Capsule()
.fill(tint ?? palette.accent)
.frame(width: barWidth, height: barHeight(index))
}
}
.frame(height: maxBarHeight)
.animation(Motion.instant, value: level)
.onAppear {
withAnimation(.linear(duration: 0.75).repeatForever(autoreverses: true)) {
phase = 1
}
}
}
private func barHeight(_ index: Int) -> CGFloat {
// Stronger level coupling + staggered phase so the meter reads as
// "alive" even at modest mic levels.
let boosted = min(1, CGFloat(level) * 1.35 + 0.08)
let base = boosted * maxBarHeight
let wobble = sin((phase * .pi * 2) + CGFloat(index) * 0.85) * (maxBarHeight * 0.22)
+ (maxBarHeight * 0.12)
let parity = 0.55 + CGFloat(index % 3) * 0.2
return max(maxBarHeight * 0.18, min(maxBarHeight, base * parity + wobble))
}
}
// MARK: - Translation display helper
/// Shared label logic for the translation control so the dashboard chip,
/// the status footer and the menu-bar popover all read identically.
enum MacTranslationDisplay {
static func label(for targetLocaleId: String, language: AppUILanguage) -> String {
let resolved = TranslationLanguageCatalog.resolve(targetLocaleId)
if TranslationLanguageCatalog.isOff(resolved.id) {
return MacL10n.string("keyboard.translation.offMenu", language: language)
}
return resolved.nativeName
}
}
// MARK: - Status footer
/// Bottom status strip: engine mode, translation target, connection —
/// kept visually quiet so it never competes with the record bar.
struct MacStatusFooter: View {
@ObservedObject var viewModel: MacDictationViewModel
@Environment(\.themePalette) private var palette
private var lang: AppUILanguage { viewModel.config.uiLanguage }
var body: some View {
HStack(spacing: Spacing.sm) {
Spacer()
// Current models, shown just before the engine mode. Label text is
// the model name only; the full "provider · model" lives in the
// hover tooltip to keep the strip quiet.
Label(asrModel.name, systemImage: "waveform")
.lineLimit(1)
.truncationMode(.middle)
.frame(maxWidth: 150, alignment: .trailing)
.help(asrModel.tooltip)
.contentTransition(.opacity)
Text("·")
.foregroundStyle(palette.textTertiary.opacity(0.5))
Label(llmModel.name, systemImage: "sparkles")
.lineLimit(1)
.truncationMode(.middle)
.frame(maxWidth: 150, alignment: .trailing)
.help(llmModel.tooltip)
.contentTransition(.opacity)
Text("·")
.foregroundStyle(palette.textTertiary.opacity(0.5))
Label(
viewModel.isCloudMode
? MacL10n.string("mac.mode.cloud", language: lang)
: MacL10n.string("mac.mode.local", language: lang),
systemImage: viewModel.isCloudMode ? "cloud" : "cpu"
)
.contentTransition(.opacity)
Text("·")
.foregroundStyle(palette.textTertiary.opacity(0.5))
Label(
MacTranslationDisplay.label(for: viewModel.config.translationTargetLocaleId, language: lang),
systemImage: "translate"
)
.contentTransition(.opacity)
Text("·")
.foregroundStyle(palette.textTertiary.opacity(0.5))
Label(MacL10n.string("mac.connected", language: lang), systemImage: "link")
.foregroundStyle(palette.accent.opacity(0.85))
}
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.labelStyle(.titleAndIcon)
.padding(.horizontal, MacMetrics.pageHorizontalInset)
.padding(.vertical, Spacing.sm)
.animation(Motion.quick, value: viewModel.isCloudMode)
.animation(Motion.quick, value: viewModel.config.translationTargetLocaleId)
.animation(Motion.quick, value: asrModel.name)
.animation(Motion.quick, value: llmModel.name)
}
// MARK: - Current model resolution
/// ASR: cloud shows the configured provider's model (or its default);
/// local shows the selected on-device model's display name.
private var asrModel: (name: String, tooltip: String) {
let config = viewModel.config
if viewModel.isCloudMode {
let providerName = ProviderDisplayName.name(for: config.asrProviderId, language: lang)
let model = config.asrModel.isEmpty
? CloudASRModelCatalog.defaultModel(for: config.asrProviderId)
: config.asrModel
return (model, "\(providerName) · \(model)")
}
let providerName = MacL10n.string("mac.mode.local", language: lang)
let model = MacLocalASRModelName.displayName(for: MacLocalASRPreferences.selectedModelId, language: lang)
return (model, "\(providerName) · \(model)")
}
/// LLM: cloud uses the configured provider; local routes through
/// `localModeProviderId` (user-supplied polish key required).
private var llmModel: (name: String, tooltip: String) {
let config = viewModel.config
let providerId = viewModel.isCloudMode ? config.providerId : config.localModeProviderId
let providerName = ProviderDisplayName.name(for: providerId, language: lang)
let model: String
if providerId == config.providerId, !config.model.isEmpty {
model = config.model
} else {
model = LLMProvider.provider(id: providerId).defaultModel
}
return (model, "\(providerName) · \(model)")
}
}
/// Cached lookup from a local ASR model id to its catalog display name. The
/// bundled catalog is small and immutable, so decoding it once is enough.
enum MacLocalASRModelName {
private static let catalog: LocalASRCatalogDocument? = try? LocalASRModelCatalog.loadBundled()
static func displayName(for modelId: String, language: AppUILanguage) -> String {
if !modelId.isEmpty,
let catalog,
let model = LocalASRModelCatalog.model(modelId, in: catalog) {
return model.displayName
}
return modelId.isEmpty ? MacL10n.string("mac.mode.local", language: language) : modelId
}
}
// MARK: - Form alignment
private struct MacFormPageAlignModifier: ViewModifier {
func body(content: Content) -> some View {
// Form stays full-bleed (scrollbar on the window edge). Section
// cards are inset to match `MacPageHeader`.
content
.contentMargins(.horizontal, MacMetrics.pageHorizontalInset, for: .scrollContent)
}
}
extension View {
/// Insets grouped-`Form` section cards to `pageHorizontalInset`.
func macFormPageAligned() -> some View {
modifier(MacFormPageAlignModifier())
}
}