From d8a2ff7f803cf4d2aa25118ca4d7886d89cff0a2 Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Mon, 25 Aug 2025 14:49:51 +0200 Subject: [PATCH 1/4] Handle empty script view state --- .../Views/Sieve Script/SieveScriptView.swift | 65 ++++++++++++++++++- .../Sieve Script/SieveScriptViewModel.swift | 26 ++------ 2 files changed, 66 insertions(+), 25 deletions(-) diff --git a/Source/Yasba/Views/Sieve Script/SieveScriptView.swift b/Source/Yasba/Views/Sieve Script/SieveScriptView.swift index 5782c8a..92d6b1a 100644 --- a/Source/Yasba/Views/Sieve Script/SieveScriptView.swift +++ b/Source/Yasba/Views/Sieve Script/SieveScriptView.swift @@ -37,14 +37,48 @@ struct SieveScriptView: 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) } diff --git a/Source/Yasba/Views/Sieve Script/SieveScriptViewModel.swift b/Source/Yasba/Views/Sieve Script/SieveScriptViewModel.swift index 245234a..2c86513 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 From e2fa53d72fdaa11cff8a093cf13edebb64104901 Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Mon, 25 Aug 2025 17:32:11 +0200 Subject: [PATCH 2/4] Add button to clear the model --- .../SieveScriptBuilderView.swift | 49 ++++++++++++------- .../Views/Sieve Script/SieveScriptView.swift | 14 +++--- .../Sieve Script/SieveScriptViewModel.swift | 7 +++ 3 files changed, 46 insertions(+), 24 deletions(-) diff --git a/Source/Yasba/Views/Sieve Script Builder/SieveScriptBuilderView.swift b/Source/Yasba/Views/Sieve Script Builder/SieveScriptBuilderView.swift index 99ab01c..be7d1a6 100644 --- a/Source/Yasba/Views/Sieve Script Builder/SieveScriptBuilderView.swift +++ b/Source/Yasba/Views/Sieve Script Builder/SieveScriptBuilderView.swift @@ -7,7 +7,7 @@ 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 = "" @@ -18,13 +18,16 @@ struct SieveScriptBuilderView: View { Color.primary.colorInvert() .ignoresSafeArea() - Toolbar() { - renderedScriptText = model.render() - shouldPresentSheet = true - } + Toolbar( + onRender: { + renderedScriptText = model.render() + shouldPresentSheet = true + }, onNew: { + model.clear() + }) .ignoresSafeArea() - SieveScriptView(viewModel: $model) + SieveScriptView(viewModel: model) .padding([.leading, .top], 24) .frame(minWidth: 420, maxWidth: .infinity, @@ -53,26 +56,38 @@ struct SieveScriptBuilderView: View { } private struct Toolbar: View { - var onRender: (() -> Void) + var onRender: () -> Void + var onNew: () -> Void var body: some View { - HStack { + HStack(spacing: 32) { Spacer() - Button { - onRender() - } label: { - Image(systemName: "note.text") - .resizable() - .frame(width: 18, height: 18) - } - .buttonStyle(.plain) - + ToolbarButton(icon: "document.badge.plus") { onNew() } + ToolbarButton(icon: "note.text") { onRender() } } .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 92d6b1a..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,10 +33,6 @@ struct SieveScriptView: View { return CGFloat(indents.last ?? 0) * 24 } - init(viewModel: Binding) { - self._viewModel = viewModel - } - var body: some View { ZStack(alignment: .center) { HStack { @@ -207,10 +207,10 @@ struct SieveScriptView: View { AddFlagCommand(tag: "Tag 2"), StopCommand() ]) - SieveScriptView(viewModel: $viewModel) + 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 2c86513..2e25594 100644 --- a/Source/Yasba/Views/Sieve Script/SieveScriptViewModel.swift +++ b/Source/Yasba/Views/Sieve Script/SieveScriptViewModel.swift @@ -52,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) From d0abff2bcbf698e1bad8987b19237d90a09a5952 Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Mon, 25 Aug 2025 17:33:42 +0200 Subject: [PATCH 3/4] Show alert before clearing the script --- .../Sieve Script Builder/SieveScriptBuilderView.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Source/Yasba/Views/Sieve Script Builder/SieveScriptBuilderView.swift b/Source/Yasba/Views/Sieve Script Builder/SieveScriptBuilderView.swift index be7d1a6..38e29f0 100644 --- a/Source/Yasba/Views/Sieve Script Builder/SieveScriptBuilderView.swift +++ b/Source/Yasba/Views/Sieve Script Builder/SieveScriptBuilderView.swift @@ -11,6 +11,7 @@ struct SieveScriptBuilderView: View { @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 { @@ -23,7 +24,7 @@ struct SieveScriptBuilderView: View { renderedScriptText = model.render() shouldPresentSheet = true }, onNew: { - model.clear() + shouldShowClearConfirmation = true }) .ignoresSafeArea() @@ -52,6 +53,14 @@ 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.") + } } } From 736ec5038b2436467703470d7445aa171b8d4d83 Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Mon, 25 Aug 2025 17:40:32 +0200 Subject: [PATCH 4/4] Only show clear when script non empty --- .../SieveScriptBuilderView.swift | 37 ++++++++++++------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/Source/Yasba/Views/Sieve Script Builder/SieveScriptBuilderView.swift b/Source/Yasba/Views/Sieve Script Builder/SieveScriptBuilderView.swift index 38e29f0..cf42cd1 100644 --- a/Source/Yasba/Views/Sieve Script Builder/SieveScriptBuilderView.swift +++ b/Source/Yasba/Views/Sieve Script Builder/SieveScriptBuilderView.swift @@ -19,14 +19,7 @@ struct SieveScriptBuilderView: View { Color.primary.colorInvert() .ignoresSafeArea() - Toolbar( - onRender: { - renderedScriptText = model.render() - shouldPresentSheet = true - }, onNew: { - shouldShowClearConfirmation = true - }) - .ignoresSafeArea() + toolbar SieveScriptView(viewModel: model) .padding([.leading, .top], 24) @@ -62,17 +55,35 @@ struct SieveScriptBuilderView: View { 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 - var onNew: () -> Void +private struct Toolbar: View { + var content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } var body: some View { HStack(spacing: 32) { Spacer() - ToolbarButton(icon: "document.badge.plus") { onNew() } - ToolbarButton(icon: "note.text") { onRender() } + content } .padding(.trailing, 24) .frame(height: 50)