diff --git a/Source/Yasba/Views/Sieve Script Builder/SieveScriptBuilderView.swift b/Source/Yasba/Views/Sieve Script Builder/SieveScriptBuilderView.swift index 99ab01c..cf42cd1 100644 --- a/Source/Yasba/Views/Sieve Script Builder/SieveScriptBuilderView.swift +++ b/Source/Yasba/Views/Sieve Script Builder/SieveScriptBuilderView.swift @@ -7,10 +7,11 @@ import AppKit // MARK: - Root struct SieveScriptBuilderView: View { - @State var model: SieveScriptViewModel + @StateObject var model: SieveScriptViewModel @State private var libraryWidth: CGFloat = 320 @State private var shouldPresentSheet = false @State private var renderedScriptText: String = "" + @State private var shouldShowClearConfirmation = false var body: some View { HSplitView { @@ -18,13 +19,9 @@ struct SieveScriptBuilderView: View { Color.primary.colorInvert() .ignoresSafeArea() - Toolbar() { - renderedScriptText = model.render() - shouldPresentSheet = true - } - .ignoresSafeArea() + toolbar - SieveScriptView(viewModel: $model) + SieveScriptView(viewModel: model) .padding([.leading, .top], 24) .frame(minWidth: 420, maxWidth: .infinity, @@ -49,30 +46,68 @@ struct SieveScriptBuilderView: View { } content: { RenderedSieveScriptView(scriptText: $renderedScriptText) } + .alert("Clear Script?", isPresented: $shouldShowClearConfirmation) { + Button("Cancel", role: .cancel) { } + Button("Clear", role: .destructive) { + model.clear() + } + } message: { + Text("This will remove all commands from the script. This action cannot be undone.") + } + } + + @ViewBuilder + private var toolbar: some View { + Toolbar { + if !model.rowTokens.isEmpty { + ToolbarButton(icon: "trash") { + shouldShowClearConfirmation = true + } + } + ToolbarButton(icon: "note.text") { + renderedScriptText = model.render() + shouldPresentSheet = true + } + } + .ignoresSafeArea() } } -private struct Toolbar: View { - var onRender: (() -> Void) +private struct Toolbar: View { + var content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } var body: some View { - HStack { + HStack(spacing: 32) { Spacer() - Button { - onRender() - } label: { - Image(systemName: "note.text") - .resizable() - .frame(width: 18, height: 18) - } - .buttonStyle(.plain) - + content } .padding(.trailing, 24) .frame(height: 50) } } +private struct ToolbarButton: View { + private let size: CGFloat = 22 + + let icon: String + let action: () -> Void + + var body: some View { + Button { + action() + } label: { + Image(systemName: icon) + .resizable() + .frame(width: size, height: size) + } + .buttonStyle(.plain) + } +} + #Preview { SieveScriptBuilderView(model: SieveScriptViewModel()) .frame(width: 1120, height: 720) diff --git a/Source/Yasba/Views/Sieve Script/SieveScriptView.swift b/Source/Yasba/Views/Sieve Script/SieveScriptView.swift index 5782c8a..b33246b 100644 --- a/Source/Yasba/Views/Sieve Script/SieveScriptView.swift +++ b/Source/Yasba/Views/Sieve Script/SieveScriptView.swift @@ -2,7 +2,7 @@ import SwiftUI import UniformTypeIdentifiers struct SieveScriptView: View { - @Binding private var viewModel: SieveScriptViewModel + @ObservedObject private var viewModel: SieveScriptViewModel @State private var draggedRange: Range? = nil @State private var dropGapIndex: Int? = nil @State private var rowHeights: [Int: CGFloat] = [:] @@ -12,6 +12,10 @@ struct SieveScriptView: View { var tokens: [RowToken] { viewModel.rowTokens } var indents: [Int] { viewModel.indentation(for: tokens) } + init(viewModel: SieveScriptViewModel) { + self.viewModel = viewModel + } + var filteredIndices: [Int] { if let span = draggedRange { return Array(tokens.indices).filter { !span.contains($0) } } return Array(tokens.indices) @@ -29,22 +33,52 @@ struct SieveScriptView: View { return CGFloat(indents.last ?? 0) * 24 } - init(viewModel: Binding) { - self._viewModel = viewModel - } - var body: some View { ZStack(alignment: .center) { HStack { Spacer() - scriptList - .frame(maxWidth: 1000) + if viewModel.rowTokens.isEmpty { + emptyDropArea + .frame(maxWidth: 1000) + } else { + scriptList + .frame(maxWidth: 1000) + } Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } .frame(maxWidth: .infinity, maxHeight: .infinity) } + + @ViewBuilder + private var emptyDropArea: some View { + VStack(spacing: 12) { + if dropGapIndex != nil || draggedRange != nil { + PlaceholderRowView() + .padding(.leading, 0) + .transition(.opacity) + } else { + Text("Drag a command from the sidebar to start writing your script.") + .font(.title2) + .foregroundStyle(Color.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .contentShape(Rectangle()) + .onDrop( + of: [UTType.utf8PlainText], + delegate: UnifiedDropDelegate( + target: .fixedGap(0), + draggedRange: $draggedRange, + dropGapIndex: $dropGapIndex, + rowHeights: $rowHeights, + viewModel: viewModel + ) + ) + } @ViewBuilder var scriptList: some View { @@ -151,7 +185,32 @@ struct SieveScriptView: View { } } -#Preview { +#Preview("Not empty") { + @Previewable @State var viewModel = SieveScriptViewModel(script: [ + AddFlagCommand(tag: "Spam"), + IfCommand( + quantifier: .any, + tests: [ + .header(["from"], match: .contains, keys: ["noreply@dpd.hu"]), + .exists(["x-marked-spam"]) + ], + thenChildren: [ + AddFlagCommand(tag: "Label 1"), + AddFlagCommand(tag: "Label 2") + ], + elseChildren: [ + AddFlagCommand(tag: "Tag 1") + ] + ), + FileIntoCommand(mailbox: "Social"), + AddFlagCommand(tag: "Tag 1"), + AddFlagCommand(tag: "Tag 2"), + StopCommand() + ]) + SieveScriptView(viewModel: viewModel) +} + +#Preview("Empty") { @Previewable @State var viewModel = SieveScriptViewModel() - SieveScriptView(viewModel: $viewModel) + SieveScriptView(viewModel: viewModel) } diff --git a/Source/Yasba/Views/Sieve Script/SieveScriptViewModel.swift b/Source/Yasba/Views/Sieve Script/SieveScriptViewModel.swift index 245234a..2e25594 100644 --- a/Source/Yasba/Views/Sieve Script/SieveScriptViewModel.swift +++ b/Source/Yasba/Views/Sieve Script/SieveScriptViewModel.swift @@ -2,27 +2,7 @@ import Combine final class SieveScriptViewModel: ObservableObject { - private var script: [AnySieveCommand] = [ - AddFlagCommand(tag: "Spam"), - IfCommand( - quantifier: .any, - tests: [ - .header(["from"], match: .contains, keys: ["noreply@dpd.hu"]), - .exists(["x-marked-spam"]) - ], - thenChildren: [ - AddFlagCommand(tag: "Label 1"), - AddFlagCommand(tag: "Label 2") - ], - elseChildren: [ - AddFlagCommand(tag: "Tag 1") - ] - ), - FileIntoCommand(mailbox: "Social"), - AddFlagCommand(tag: "Tag 1"), - AddFlagCommand(tag: "Tag 2"), - StopCommand() - ] + private var script: [AnySieveCommand] = [] private let mapper: RowTokenMapping private let editor: RowTokenEditing @@ -32,9 +12,11 @@ final class SieveScriptViewModel: ObservableObject { mapper.tokens(from: script) } - init(mapper: RowTokenMapping = RowTokenMapper(), + init(script: [AnySieveCommand] = [], + mapper: RowTokenMapping = RowTokenMapper(), editor: RowTokenEditing = RowTokenEditor(), scriptRenderer: SieveScriptRenderer = .default) { + self.script = script self.mapper = mapper self.editor = editor self.scriptRenderer = scriptRenderer @@ -70,8 +52,15 @@ final class SieveScriptViewModel: ObservableObject { func render() -> String { return scriptRenderer.render(commands: script) } + + func clear() { + applyTokenEdit { tokens in + tokens = [] + } + } private func applyTokenEdit(_ transform: (inout [RowToken]) -> Void) { + objectWillChange.send() var tokens = mapper.tokens(from: script) transform(&tokens) self.script = mapper.script(from: tokens)