diff --git a/Localization/Localizable.xcstrings b/Localization/Localizable.xcstrings index 3b806d1..125cc23 100644 --- a/Localization/Localizable.xcstrings +++ b/Localization/Localizable.xcstrings @@ -11036,6 +11036,16 @@ } } }, + "Unsaved changes": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "저장하지 않은 변경 사항" + } + } + } + }, "Unsupported Fn mapping: %@ → %@. Use letter keys and up/down/left/right.": { "localizations": { "en": { diff --git a/Sources/MenubucketApp/Automation/AutomationController.swift b/Sources/MenubucketApp/Automation/AutomationController.swift index f9ad501..d9f7e1b 100644 --- a/Sources/MenubucketApp/Automation/AutomationController.swift +++ b/Sources/MenubucketApp/Automation/AutomationController.swift @@ -13,6 +13,10 @@ final class AutomationController: ObservableObject { } @Published private(set) var source = AutomationScript.example @Published private(set) var isRunning = false + /// Unsaved edits in the settings editor; nil when the editor shows the + /// saved script. Kept here rather than in the view, so leaving the page + /// and coming back does not throw edits away. + @Published private(set) var draft: String? @Published private(set) var message: String? @Published private(set) var importSummary: String? /// Running bindings that macOS also uses for one of its shortcuts. @@ -59,6 +63,11 @@ final class AutomationController: ObservableObject { apply(source: source, enabled: true) } + /// Records editor text; matching the saved script clears the draft. + func setDraft(_ text: String) { + draft = text == source ? nil : text + } + @discardableResult func apply(source candidate: String, enabled: Bool) -> Bool { do { diff --git a/Sources/MenubucketApp/Automation/AutomationSettingsView.swift b/Sources/MenubucketApp/Automation/AutomationSettingsView.swift index 08642a0..a5b502f 100644 --- a/Sources/MenubucketApp/Automation/AutomationSettingsView.swift +++ b/Sources/MenubucketApp/Automation/AutomationSettingsView.swift @@ -4,7 +4,9 @@ import UniformTypeIdentifiers struct AutomationSettingsView: View { @ObservedObject private var controller: AutomationController - @State private var draft = "" + /// The editor's text: unsaved edits if there are any, else the saved script. + private var draft: String { controller.draft ?? controller.source } + private var hasUnsavedChanges: Bool { controller.draft != nil } @State private var displays: [AutomationWindows.Display] = [] init(controller: AutomationController = .shared) { @@ -17,7 +19,9 @@ struct AutomationSettingsView: View { Toggle("Enable Keyboard & Window Extension", isOn: Binding( get: { controller.isRunning }, set: { enabled in - if enabled { controller.apply(source: draft, enabled: true) } + // Enabling runs the saved script. Unsaved edits are + // applied only by Save, as the editor says. + if enabled { controller.apply(source: controller.source, enabled: true) } else { controller.disable() } } )) @@ -60,7 +64,7 @@ struct AutomationSettingsView: View { URL(fileURLWithPath: $0, isDirectory: true) } ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config") if let imported = controller.importFile(base.appendingPathComponent("karabiner/karabiner.json")) { - draft = imported + controller.setDraft(imported) } } Button("Karabiner Key Mappings Only…") { @@ -79,19 +83,30 @@ struct AutomationSettingsView: View { if let summary = controller.importSummary { Text(summary).font(.caption).textSelection(.enabled) } - TextEditor(text: $draft) + TextEditor(text: Binding(get: { draft }, set: { controller.setDraft($0) })) .font(.system(.body, design: .monospaced)) .frame(minHeight: 230) .border(Fill.hairline) .accessibilityLabel("Automation JavaScript") HStack { Button(controller.isRunning ? String(localized: "Save & Reload") : String(localized: "Save Script")) { - controller.apply(source: draft, enabled: controller.isRunning) + if controller.apply(source: draft, enabled: controller.isRunning) { + controller.setDraft(controller.source) + } } - Button("Revert Draft") { draft = controller.source } - .disabled(draft == controller.source) - Button("Load Example") { draft = AutomationScript.example } + // Running, it doubles as Reload. + .disabled(!hasUnsavedChanges && !controller.isRunning) + Button("Revert Draft") { controller.setDraft(controller.source) } + .disabled(!hasUnsavedChanges) + Button("Load Example") { controller.setDraft(AutomationScript.example) } Button("Export…") { export() } + if hasUnsavedChanges { + Label("Unsaved changes", systemImage: "circle.fill") + .labelStyle(.titleAndIcon) + .font(.caption) + .foregroundStyle(StatusTone.warning.color) + .imageScale(.small) + } } Text("Edit shortcuts and window actions in JavaScript. Save applies your changes; Revert Draft restores the saved script.") .font(.caption).foregroundStyle(.secondary) @@ -107,7 +122,6 @@ struct AutomationSettingsView: View { } header: { Text("Displays") } } .onAppear { - draft = controller.source displays = AutomationWindows.displays } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification)) { _ in @@ -168,12 +182,12 @@ struct AutomationSettingsView: View { panel.allowsMultipleSelection = false panel.canChooseDirectories = false guard panel.runModal() == .OK, let url = panel.url else { return } - if let imported = controller.importFile(url, excludingFunctionKeys: keyMappingsOnly) { draft = imported } + if let imported = controller.importFile(url, excludingFunctionKeys: keyMappingsOnly) { controller.setDraft(imported) } } private func importSettings(path: String) { let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(path) - if let imported = controller.importFile(url) { draft = imported } + if let imported = controller.importFile(url) { controller.setDraft(imported) } } private func export() { diff --git a/Sources/MenubucketApp/Dock/DockSettingsPage.swift b/Sources/MenubucketApp/Dock/DockSettingsPage.swift index 77027dc..805090e 100644 --- a/Sources/MenubucketApp/Dock/DockSettingsPage.swift +++ b/Sources/MenubucketApp/Dock/DockSettingsPage.swift @@ -46,11 +46,22 @@ struct DockSettingsPage: View { } } } - appearanceSection - profilesSection - itemsSection - appleDockSection - switchingSection + if config.mode == .off { + // With the BarShelf Dock off, profiles still switch the Apple + // Dock: lead with them, and keep the BarShelf Dock's own + // look and items for when it is turned on. + profilesSection + appleDockSection + switchingSection + appearanceSection + itemsSection + } else { + appearanceSection + profilesSection + itemsSection + appleDockSection + switchingSection + } } .alert("Add Link", isPresented: $showLinkPrompt) { TextField("https://example.com", text: $linkDraft) diff --git a/Tests/MenubucketAppTests/AutomationTests.swift b/Tests/MenubucketAppTests/AutomationTests.swift index d220fad..9737af3 100644 --- a/Tests/MenubucketAppTests/AutomationTests.swift +++ b/Tests/MenubucketAppTests/AutomationTests.swift @@ -236,6 +236,24 @@ final class AutomationTests: XCTestCase { XCTAssertFalse(restored.isRunning) } + func testUnsavedDraftOutlivesTheEditorAndClearsWhenItMatchesTheSavedScript() throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: folder) } + let controller = AutomationController(fileURL: folder.appendingPathComponent("automation.json")) + let saved = "barshelf.bind(['ctrl'], 'i', () => moveMouseToScreen(1));" + XCTAssertTrue(controller.apply(source: saved, enabled: false)) + XCTAssertNil(controller.draft) + + // An edit is held by the controller, so a settings page that is left + // and rebuilt shows it again instead of the saved script. + controller.setDraft(saved + "\n// edit") + XCTAssertEqual(controller.draft, saved + "\n// edit") + XCTAssertEqual(controller.source, saved, "a draft is not saved") + + controller.setDraft(saved) + XCTAssertNil(controller.draft, "text equal to the saved script is no draft") + } + func testImportIsOnlyADraftAndDoesNotOverwriteSavedSourceOrOriginal() throws { let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)