Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Localization/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
9 changes: 9 additions & 0 deletions Sources/MenubucketApp/Automation/AutomationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
36 changes: 25 additions & 11 deletions Sources/MenubucketApp/Automation/AutomationSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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() }
}
))
Expand Down Expand Up @@ -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…") {
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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() {
Expand Down
21 changes: 16 additions & 5 deletions Sources/MenubucketApp/Dock/DockSettingsPage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions Tests/MenubucketAppTests/AutomationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading