import SstData import SstUI import SwiftUI struct DictionaryView: View { enum EditorTarget: Identifiable, Equatable { case new case existing(UUID) var id: String { switch self { case .new: "new" case .existing(let id): id.uuidString } } } @EnvironmentObject private var model: AppModel @EnvironmentObject private var settings: AppSettings @ObservedObject var list: DictionaryViewModel @State private var selection: UUID? @State private var editing: EditorTarget? @FocusState private var searchFocused: Bool var body: some View { VStack(spacing: Layout.panelGap) { header ZStack { PaperBackground() table } footer } .sheet(item: $editing) { target in EntryEditor(list: list, target: target) { editing = nil } } } private var header: some View { VStack(spacing: Layout.panelGap) { HStack(spacing: Space.s4) { SearchField(text: $list.query, focused: $searchFocused) Button(Strings.add) { editing = .new }.buttonStyle(KeyButtonStyle()) Button(Strings.openFile) { model.openDictionaryFile() }.buttonStyle(KeyButtonStyle()) } HStack(spacing: Layout.panelGap) { Toggle(isOn: $settings.usePriming) { SilkLabel(Strings.priming) } .toggleStyle(RockerToggleStyle()) Toggle(isOn: $settings.useCorrection) { SilkLabel(Strings.correcting) } .toggleStyle(RockerToggleStyle()) Spacer() } } } private var table: some View { VStack(spacing: 0) { HStack(spacing: Space.s4) { Color.clear.frame(width: Size.iconMedium, height: Size.iconMedium) SilkLabel(Strings.heard).frame(maxWidth: .infinity, alignment: .leading) SilkLabel(Strings.wrote).frame(maxWidth: .infinity, alignment: .leading) } .padding(.horizontal, Space.s4) .padding(.vertical, Space.s3) if list.visibleRows.isEmpty { Text(list.rows.isEmpty ? Strings.emptyDictionary : Strings.noResults) .textStyle(Typography.body) .foregroundStyle(Palette.inkSecondary) .padding(Layout.panelPadding) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } else { ScrollList { ForEach(list.visibleRows) { row in DictionaryRow(row: row, isSelected: selection == row.id) .onTapGesture(count: 2) { editing = .existing(row.id) } .onTapGesture { selection = row.id } } } } } .focusable() .onDeleteCommand { if let id = selection { list.remove(id: id) } } .onKeyPress(KeyEquivalent("z"), phases: .down) { press in guard press.modifiers == .command, list.canUndo else { return .ignored } list.undo() return .handled } } private var footer: some View { HStack(spacing: Layout.panelGap) { SilkLabel(Strings.entries(list.rows.count), style: Typography.labelSmall) if let message = list.errorMessage { Text(message).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary) } Spacer() Button(Strings.undo) { list.undo() } .buttonStyle(KeyButtonStyle()) .disabled(!list.canUndo) } } } private struct DictionaryRow: View { let row: DictionaryViewModel.Row let isSelected: Bool var body: some View { HStack(spacing: Space.s4) { lamp .frame(width: Size.iconMedium, height: Size.iconMedium) if let heard = heard { Text(heard).textStyle(Typography.code).foregroundStyle(Palette.inkPrimary) .frame(maxWidth: .infinity, alignment: .leading) } else { Text("—").textStyle(Typography.code).foregroundStyle(Palette.inkSecondary) .frame(maxWidth: .infinity, alignment: .leading) } Text(row.entry.wrote).textStyle(Typography.code).foregroundStyle(Palette.inkPrimary) .frame(maxWidth: .infinity, alignment: .leading) } .padding(.horizontal, Space.s4) .padding(.vertical, Space.s3) .frame(minHeight: Size.rowHeight) .background(isSelected ? Palette.surfaceSelection : Color.clear) .contentShape(Rectangle()) .help(row.warnings.map(\.message).joined(separator: "\n")) } private var heard: String? { if case .correction(let heard, _) = row.entry.kind { return heard } return nil } @ViewBuilder private var lamp: some View { switch row.worst { case .error: LampView(isOn: true) case .warning: LampView(isOn: true, tint: .amber) default: LampView(isOn: false, tint: .amber) } } } /// Add or edit one entry, with live warnings. private struct EntryEditor: View { @ObservedObject var list: DictionaryViewModel let target: DictionaryView.EditorTarget let onClose: () -> Void @State private var isCorrection = false @State private var heard = "" @State private var wrote = "" private var editingID: UUID? { if case .existing(let id) = target { return id } return nil } private var kind: DictionaryEntry.Kind { let write = wrote.trimmingCharacters(in: .whitespaces) return isCorrection ? .correction(heard: heard.trimmingCharacters(in: .whitespaces), wrote: write) : .term(write) } private var warnings: [EntryWarning] { let emptySource = isCorrection ? heard.trimmingCharacters(in: .whitespaces).isEmpty : wrote.trimmingCharacters(in: .whitespaces).isEmpty guard !emptySource, !wrote.trimmingCharacters(in: .whitespaces).isEmpty else { return [] } return list.warnings(for: kind, editing: editingID) } private var canSave: Bool { let hasText = !wrote.trimmingCharacters(in: .whitespaces).isEmpty && (!isCorrection || !heard.trimmingCharacters(in: .whitespaces).isEmpty) return hasText && !warnings.contains { $0.level == .error } } var body: some View { ZStack { BrushedBackground().ignoresSafeArea() VStack(alignment: .leading, spacing: Layout.panelGap) { HStack(spacing: Space.s3) { Button(Strings.term) { isCorrection = false }.buttonStyle(KeyButtonStyle(latched: !isCorrection)) Button(Strings.correction) { isCorrection = true }.buttonStyle(KeyButtonStyle(latched: isCorrection)) } VStack(alignment: .leading, spacing: Layout.panelGap) { if isCorrection { field(Strings.heard, text: $heard) } field(Strings.wrote, text: $wrote) ForEach(Array(warnings.enumerated()), id: \.offset) { _, warning in HStack(alignment: .firstTextBaseline, spacing: Space.s3) { LampView(isOn: warning.level != .note, tint: warning.level == .error ? .record : .amber) Text(warning.message).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary) } } } .padding(Layout.panelPadding) .frame(maxWidth: .infinity, alignment: .leading) .background(PanelBackground()) HStack(spacing: Space.s3) { Spacer() Button(Strings.cancel, action: onClose).buttonStyle(KeyButtonStyle()) Button(Strings.save, action: save).buttonStyle(KeyButtonStyle(kind: .record)).disabled(!canSave) } } .padding(Layout.panelPadding) } .frame(width: Layout.settingsWidth) .onAppear(perform: load) } private func field(_ title: String, text: Binding) -> some View { HStack(spacing: Space.s4) { SilkLabel(title).frame(width: Size.keyRecWidth, alignment: .leading) TextField("", text: text) .textFieldStyle(.plain) .textStyle(Typography.code) .foregroundStyle(Palette.inkPrimary) .padding(Space.s3) .background(WellBackground()) } } private func load() { guard let id = editingID, let row = list.rows.first(where: { $0.id == id }) else { return } switch row.entry.kind { case .term(let term): (isCorrection, heard, wrote) = (false, "", term) case .correction(let h, let w): (isCorrection, heard, wrote) = (true, h, w) } } private func save() { if let id = editingID { list.update(id: id, to: kind) } else { list.add(kind) } onClose() } }