import AppKit import SstCore import SstData import SstUI import SwiftUI private let dutch = Locale(identifier: "nl_NL") struct HistoryView: View { @EnvironmentObject private var model: AppModel @ObservedObject var list: HistoryViewModel @FocusState private var searchFocused: Bool @FocusState private var listFocused: Bool var body: some View { VStack(spacing: Layout.panelGap) { SearchField(text: $list.query, focused: $searchFocused) ZStack { PaperBackground() content } } .onChange(of: model.searchFocusTick) { _, _ in searchFocused = true } } @ViewBuilder private var content: some View { if list.days.isEmpty { Text(emptyMessage) .textStyle(Typography.body) .foregroundStyle(Palette.inkSecondary) .padding(Layout.panelPadding) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } else { ScrollList { ForEach(list.days) { day in Section { ForEach(day.entries) { entry in HistoryRow(entry: entry, isSelected: list.selection == entry.id, list: list) } } header: { SilkLabel(day.day.formatted(Date.FormatStyle(date: .complete, time: .omitted).locale(dutch))) .padding(.horizontal, Space.s4) .padding(.vertical, Space.s3) .frame(maxWidth: .infinity, alignment: .leading) .background(Palette.surfacePaper) } } } .focusable() .focused($listFocused) .focusEffectDisabled() .onChange(of: list.selection) { _, new in if new != nil { listFocused = true } } .onDeleteCommand { if let id = list.selection { list.delete(id: id) } } } } private var emptyMessage: String { if list.totalCount > 0 { return Strings.noResults } return model.settings.keepHistory ? Strings.noTranscripts : Strings.historyOff } } private struct HistoryRow: View { @EnvironmentObject private var model: AppModel let entry: HistoryEntry let isSelected: Bool @ObservedObject var list: HistoryViewModel var body: some View { HStack(alignment: .firstTextBaseline, spacing: Space.s4) { Text(entry.date.formatted(Date.FormatStyle().hour().minute().second().locale(dutch))) .textStyle(Typography.counterSmall) .foregroundStyle(Palette.inkSecondary) VStack(alignment: .leading, spacing: Space.s2) { Text(markedText) .textStyle(Typography.reading) .foregroundStyle(Palette.inkPrimary) .textSelection(.enabled) if !entry.corrections.isEmpty { SilkLabel(correctionSummary, style: Typography.labelSmall) } if isSelected && entry.rawText != entry.finalText { Text("\(Strings.original): \(entry.rawText)") .textStyle(Typography.meta) .foregroundStyle(Palette.inkSecondary) } } Spacer(minLength: Space.s4) Button(Strings.copy) { copy(entry.finalText) } .buttonStyle(KeyButtonStyle()) Button(Strings.edit) { model.edit(entry: entry.id) } .buttonStyle(KeyButtonStyle()) Button(Strings.delete) { list.delete(id: entry.id) } .buttonStyle(KeyButtonStyle()) } .padding(.horizontal, Space.s4) .padding(.vertical, Space.s3) .frame(minHeight: Size.rowHeight) .background(isSelected ? Palette.surfaceSelection : Color.clear) .contentShape(Rectangle()) .onTapGesture { list.selection = entry.id } .contextMenu { Button(Strings.copy) { copy(entry.finalText) } Button(Strings.copyOriginal) { copy(entry.rawText) } Button(Strings.edit) { model.edit(entry: entry.id) } Divider() Button(Strings.delete, role: .destructive) { list.delete(id: entry.id) } } } /// The final text with every dictionary replacement highlighted. private var markedText: AttributedString { var result = AttributedString() for segment in CorrectionMarkup.segments(finalText: entry.finalText, corrections: entry.corrections) { var piece = AttributedString(segment.text) if segment.correction != nil { piece.backgroundColor = Palette.levelAmber.opacity(Opacity.mark) } result += piece } return result } private var correctionSummary: String { let changes = entry.corrections.map { "\($0.heard) → \($0.wrote)" }.joined(separator: ", ") return "\(Strings.corrections(entry.corrections.count)) · \(changes)" } private func copy(_ text: String) { NSPasteboard.general.clearContents() NSPasteboard.general.setString(text, forType: .string) } }