498f407585
Introduce optional Apple account-backed credits with scoped gateway access while preserving local and BYOK paths. Refresh assistant behavior, tests, privacy disclosures, docs, and the website for the 2.0 experience.
702 lines
27 KiB
Swift
702 lines
27 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 surface colour + hairline border 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))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
|
.stroke(palette.divider, lineWidth: 0.5)
|
|
)
|
|
}
|
|
.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?
|
|
/// Hairline divider border — drawn for the neutral variant, hidden for
|
|
/// colored fills (delete / download) so the fill reads as the button.
|
|
var showsBorder: Bool = true
|
|
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)
|
|
.overlay {
|
|
if showsBorder {
|
|
shape.stroke(palette.divider, lineWidth: 0.5)
|
|
}
|
|
}
|
|
}
|
|
.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 {
|
|
@Environment(\.themePalette) private var palette
|
|
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)
|
|
.overlay(
|
|
shape
|
|
.stroke(palette.divider, lineWidth: 0.5)
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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())
|
|
}
|
|
}
|