diff --git a/Sources/Sst/DictionaryView.swift b/Sources/Sst/DictionaryView.swift index cfe14db..43d5228 100644 --- a/Sources/Sst/DictionaryView.swift +++ b/Sources/Sst/DictionaryView.swift @@ -2,8 +2,242 @@ import SstData import SstUI import SwiftUI -// Temporary: replaced in Task 5. 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 - var body: some View { PaperBackground() } + @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() + } }