feat: add dictionary view with editor, warnings and undo
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<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 {
|
||||
list.update(id: id, to: kind)
|
||||
} else {
|
||||
list.add(kind)
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user