feat: add dictionary view with editor, warnings and undo

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 15:50:44 +02:00
co-authored by Claude Sonnet 5.5
parent 6fe1f34df2
commit 9a5794c1e0
+236 -2
View File
@@ -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()
}
}