264 lines
10 KiB
Swift
264 lines
10 KiB
Swift
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
|
|
@FocusState private var listFocused: 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
|
|
listFocused = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.focusable()
|
|
.focused($listFocused)
|
|
.focusEffectDisabled()
|
|
.onDeleteCommand {
|
|
if let id = selection {
|
|
list.remove(id: id)
|
|
selection = nil
|
|
}
|
|
}
|
|
.onChange(of: model.searchFocusTick) { _, _ in searchFocused = true }
|
|
.onChange(of: list.rows) { _, rows in
|
|
if let s = selection, !rows.contains(where: { $0.id == s }) { selection = nil }
|
|
}
|
|
.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())
|
|
.keyboardShortcut(.cancelAction)
|
|
Button(Strings.save, action: save).buttonStyle(KeyButtonStyle(kind: .record)).disabled(!canSave)
|
|
.keyboardShortcut(.defaultAction)
|
|
}
|
|
}
|
|
.padding(Layout.panelPadding)
|
|
}
|
|
.frame(width: Layout.settingsWidth)
|
|
.onAppear(perform: load)
|
|
}
|
|
|
|
private func field(_ title: String, text: Binding<String>) -> 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 {
|
|
guard list.rows.contains(where: { $0.id == id }) else {
|
|
list.errorMessage = Strings.entryGone
|
|
onClose()
|
|
return
|
|
}
|
|
list.update(id: id, to: kind)
|
|
} else {
|
|
list.add(kind)
|
|
}
|
|
onClose()
|
|
}
|
|
}
|