Files
OSGKeyboard/OSGKeyboardMac/MacRootView.swift
Rocky 320dc777c9 feat(keyboard): add Notes skill and system-style typing
Add save-to-Notes export and direct map handoff while aligning multi-touch typing, period shortcuts, return actions, and navigation visuals with system behavior.
2026-08-14 18:22:41 +08:00

149 lines
5.2 KiB
Swift

// MacRootView.swift
// OSGKeyboard · Mac
//
// Window shell built on the native `NavigationSplitView` so the desktop app
// reads like macOS System Settings: traffic lights float over a borderless
// sidebar, no separate title bar. The same structure lifts cleanly onto
// iPadOS later (NavigationSplitView is cross-platform).
import SwiftUI
struct MacRootView: View {
@ObservedObject var viewModel: MacDictationViewModel
@Environment(\.themePalette) private var palette
@Environment(\.openWindow) private var openWindow
@State private var columnVisibility: NavigationSplitViewVisibility = .all
private var uiLanguage: AppUILanguage { viewModel.config.uiLanguage }
var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
sidebar
.navigationSplitViewColumnWidth(MacMetrics.sidebarWidth)
} detail: {
detail
}
.navigationSplitViewStyle(.balanced)
.frame(minWidth: MacMetrics.windowMinWidth, minHeight: MacMetrics.windowMinHeight)
.onAppear {
// Let the AppKit status-bar popover reopen this window on demand.
MacWindowBridge.shared.open = { openWindow(id: "main") }
}
}
// MARK: - Sidebar
private var sidebar: some View {
VStack(spacing: 0) {
brandHeader
VStack(spacing: 6) {
ForEach(MacSection.allCases) { section in
MacSidebarRow(
section: section,
isSelected: viewModel.selectedSection == section,
language: uiLanguage
) {
withAnimation(Motion.soft) { viewModel.selectedSection = section }
}
}
}
.padding(.horizontal, MacMetrics.sidebarInset)
Spacer()
devicesFooter
}
}
/// Brand mark pinned above the nav list. Top padding clears the traffic
/// lights that now float over the borderless sidebar.
private var brandHeader: some View {
HStack {
Image("OSGLogoWide")
.renderingMode(.template)
.resizable()
.scaledToFit()
.frame(height: 28)
.foregroundStyle(palette.accent)
.accessibilityLabel("OSGKeyboard")
Spacer()
}
.padding(.leading, MacMetrics.sidebarContentInset)
.padding(.trailing, MacMetrics.sidebarInset)
.padding(.top, 30)
.padding(.bottom, 30)
}
private var devicesFooter: some View {
Label(
MacL10n.string("mac.devices", language: uiLanguage),
systemImage: "laptopcomputer.and.iphone"
)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, MacMetrics.sidebarInset + Spacing.sm)
.padding(.vertical, Spacing.sm)
}
// MARK: - Detail
private var detail: some View {
VStack(spacing: 0) {
Group {
switch viewModel.selectedSection {
case .dashboard: DashboardView(viewModel: viewModel)
case .history: MacHistoryView(viewModel: viewModel)
case .dictionary: MacDictionaryView(viewModel: viewModel)
case .styles: MacPolishStylesView(viewModel: viewModel)
case .settings: MacSettingsView(viewModel: viewModel)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.id(viewModel.selectedSection)
.transition(.opacity)
MacStatusFooter(viewModel: viewModel)
}
.background(palette.background)
}
}
// MARK: - Sidebar row
/// Navigation row with a restrained selected state: muted accent fill +
/// accent label (not a solid green pill), matching System Settings polish.
private struct MacSidebarRow: View {
let section: MacSection
let isSelected: Bool
let language: AppUILanguage
let action: () -> Void
@Environment(\.themePalette) private var palette
@State private var isHovering = false
var body: some View {
Button(action: action) {
Label(section.title(language: language), systemImage: section.systemImage(selected: isSelected))
.font(.system(size: 13, weight: isSelected ? .semibold : .regular))
.foregroundStyle(isSelected ? palette.accent : palette.textPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 9)
.background(
rowBackground,
in: RoundedRectangle(cornerRadius: 8, style: .continuous)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.animation(Motion.quick, value: isSelected)
.animation(Motion.quick, value: isHovering)
.onHover { isHovering = $0 }
}
private var rowBackground: Color {
if isSelected { return palette.accentMuted }
return isHovering ? palette.textPrimary.opacity(0.05) : .clear
}
}