From 86f3983087e848f12097548e2c144e8931978ea1 Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Thu, 1 Oct 2026 15:46:46 +0200 Subject: [PATCH] feat: add deck, sidebar with controls and the main window layout Co-Authored-By: Claude Sonnet 5.5 --- Sources/Sst/DeckView.swift | 74 ++++++++++++++++++++++++++++++++ Sources/Sst/DictionaryView.swift | 9 ++++ Sources/Sst/HistoryView.swift | 9 ++++ Sources/Sst/MainWindow.swift | 28 +++++++----- Sources/Sst/Sidebar.swift | 63 +++++++++++++++++++++++++++ 5 files changed, 172 insertions(+), 11 deletions(-) create mode 100644 Sources/Sst/DeckView.swift create mode 100644 Sources/Sst/DictionaryView.swift create mode 100644 Sources/Sst/HistoryView.swift create mode 100644 Sources/Sst/Sidebar.swift diff --git a/Sources/Sst/DeckView.swift b/Sources/Sst/DeckView.swift new file mode 100644 index 0000000..b324c17 --- /dev/null +++ b/Sources/Sst/DeckView.swift @@ -0,0 +1,74 @@ +import SstData +import SstUI +import SwiftUI + +/// The transport deck: VU meter, counter, LED bar and the REC/STOP keys. Always visible at the top. +struct DeckView: View { + @EnvironmentObject private var model: AppModel + + var body: some View { + HStack(alignment: .top, spacing: Layout.panelGap) { + instruments + Spacer(minLength: 0) + controls + } + .padding(Layout.panelPadding) + .background(PanelBackground()) + } + + private var instruments: some View { + TimelineView(.animation(minimumInterval: 1 / Motion.needleUpdateHz, paused: !model.isRecording)) { context in + let dbfs = Double(model.levelSource.dbfs) + HStack(alignment: .top, spacing: Layout.panelGap) { + VUMeterView(levelDbfs: dbfs, isActive: model.isRecording, brand: AppIdentity.brandCaption) + .frame(width: MeterTokens.vuWidth) + VStack(alignment: .leading, spacing: Layout.panelGap) { + SilkLabel(Strings.time, style: Typography.labelSmall) + SegmentCounterView(StopwatchFormat.string(elapsed(at: context.date))) + SilkLabel(Strings.level, style: Typography.labelSmall) + LEDBarView(fraction: model.isRecording ? VUScale.fraction(forDbfs: dbfs) : 0) + } + } + } + } + + private func elapsed(at date: Date) -> TimeInterval { + if let started = model.recordingStartedAt { return date.timeIntervalSince(started) } + return model.lastDuration ?? 0 + } + + private var controls: some View { + VStack(alignment: .trailing, spacing: Layout.panelGap) { + HStack(spacing: Layout.panelGap) { + Button { model.toggleWindowRecording() } label: { + HStack(spacing: Space.s3) { + LampView(isOn: model.isRecording) + Text(Strings.record) + } + } + .buttonStyle(KeyButtonStyle(kind: .record, latched: model.isRecording)) + .disabled(!model.isRecording && model.recordingBlocker != nil) + .help(model.recordingBlocker ?? "") + + Button(Strings.stop) { model.stopWindowRecording() } + .buttonStyle(KeyButtonStyle()) + .disabled(!model.isRecording) + } + if let error = model.lastError { + HStack(spacing: Space.s3) { + LampView(isOn: true, tint: .amber) + Text(error) + .textStyle(Typography.meta) + .foregroundStyle(Palette.inkSecondary) + .multilineTextAlignment(.trailing) + } + } else { + Text(model.statusLine) + .textStyle(Typography.meta) + .foregroundStyle(Palette.inkSecondary) + .multilineTextAlignment(.trailing) + } + } + .frame(maxWidth: Layout.sidebarWidth, alignment: .trailing) + } +} diff --git a/Sources/Sst/DictionaryView.swift b/Sources/Sst/DictionaryView.swift new file mode 100644 index 0000000..cfe14db --- /dev/null +++ b/Sources/Sst/DictionaryView.swift @@ -0,0 +1,9 @@ +import SstData +import SstUI +import SwiftUI + +// Temporary: replaced in Task 5. +struct DictionaryView: View { + @ObservedObject var list: DictionaryViewModel + var body: some View { PaperBackground() } +} diff --git a/Sources/Sst/HistoryView.swift b/Sources/Sst/HistoryView.swift new file mode 100644 index 0000000..b5132eb --- /dev/null +++ b/Sources/Sst/HistoryView.swift @@ -0,0 +1,9 @@ +import SstData +import SstUI +import SwiftUI + +// Temporary: replaced in Task 4. +struct HistoryView: View { + @ObservedObject var list: HistoryViewModel + var body: some View { PaperBackground() } +} diff --git a/Sources/Sst/MainWindow.swift b/Sources/Sst/MainWindow.swift index ef1550c..42da340 100644 --- a/Sources/Sst/MainWindow.swift +++ b/Sources/Sst/MainWindow.swift @@ -1,23 +1,29 @@ -import SwiftUI import SstUI +import SwiftUI -/// Placeholder shell; phase 5 replaces the content with the deck, history and dictionary. struct MainWindow: View { @EnvironmentObject private var model: AppModel var body: some View { ZStack { BrushedBackground().ignoresSafeArea() - VStack(alignment: .leading, spacing: Layout.panelGap) { - SilkLabel(AppIdentity.brandCaption) - Text(model.statusLine) - .textStyle(Typography.reading) - .foregroundStyle(Palette.inkPrimary) - .padding(Layout.panelPadding) - .frame(maxWidth: .infinity, alignment: .leading) - .background(PanelBackground()) + HStack(spacing: 0) { + Sidebar() + VStack(spacing: Layout.panelGap) { + DeckView() + switch model.section { + case .transcripts: HistoryView(list: model.historyList) + case .dictionary: DictionaryView(list: model.dictionaryList) + } + } + .padding(Layout.panelPadding) } - .padding(Layout.panelPadding) + } + .confirmationDialog(Strings.clearHistoryConfirm, isPresented: $model.confirmingClearHistory) { + Button(Strings.erase, role: .destructive) { model.clearHistory() } + Button(Strings.cancel, role: .cancel) {} + } message: { + Text(Strings.clearHistoryDetail) } } } diff --git a/Sources/Sst/Sidebar.swift b/Sources/Sst/Sidebar.swift new file mode 100644 index 0000000..6cea23d --- /dev/null +++ b/Sources/Sst/Sidebar.swift @@ -0,0 +1,63 @@ +import SstUI +import SwiftUI + +struct Sidebar: View { + @EnvironmentObject private var model: AppModel + + var body: some View { + VStack(alignment: .leading, spacing: Layout.panelGap) { + SilkLabel(AppIdentity.brandCaption) + ForEach(AppModel.Section.allCases) { section in + Button { + model.section = section + } label: { + Text(title(for: section)).frame(maxWidth: .infinity) + } + .buttonStyle(KeyButtonStyle(latched: model.section == section)) + } + Spacer() + ControlsBlock() + } + .padding(Layout.panelPadding) + .frame(width: Layout.sidebarWidth) + } + + private func title(for section: AppModel.Section) -> String { + switch section { + case .transcripts: Strings.transcripts + case .dictionary: Strings.dictionary + } + } +} + +/// Four lamps: the three macOS permissions and the speech model. +struct ControlsBlock: View { + @EnvironmentObject private var model: AppModel + + var body: some View { + VStack(alignment: .leading, spacing: Space.s3) { + SilkLabel(Strings.controls) + SeamDivider() + ForEach(PermissionMonitor.Kind.allCases) { kind in + let granted = model.permissions.isGranted(kind) + HStack(spacing: Space.s3) { + LampView(isOn: granted, tint: .amber) + Text(kind.title).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary) + Spacer() + if !granted { + Button(Strings.open) { model.permissions.openSettings(for: kind) } + .buttonStyle(KeyButtonStyle()) + .help(Strings.openSystemSettings) + } + } + } + HStack(spacing: Space.s3) { + LampView(isOn: model.modelReady, tint: .amber) + Text(Strings.modelLamp).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary) + Spacer() + } + } + .onAppear { model.permissions.startPolling() } + .onDisappear { model.permissions.stopPolling() } + } +}