From f98ad30b9c3062bb888f821fe8d5532495a2fd19 Mon Sep 17 00:00:00 2001 From: Peter Pistorius Date: Tue, 28 Apr 2026 22:22:05 +0200 Subject: [PATCH 1/3] Add built-in Settings window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A SwiftUI Settings window opens from the Dock menu's "Settings…" item, or by clicking the Dock icon when no windows are visible. Toggles for "Animations" and "Live previews" save to ~/Library/Application Support/ cmdcmd/config.json on change and apply live without restart. Saving via the UI re-encodes the JSON and drops template comments; hand-edited bindings/trigger are preserved. Trigger and bindings are not exposed in this window — edit config.json directly via "Open Config…". Co-Authored-By: plyght Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/feed10f9.md | 5 ++ Sources/cmdcmd/Config.swift | 9 +++ Sources/cmdcmd/Overlay.swift | 6 +- Sources/cmdcmd/SettingsWindow.swift | 110 ++++++++++++++++++++++++++++ Sources/cmdcmd/main.swift | 29 +++++++- 5 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 .changeset/feed10f9.md create mode 100644 Sources/cmdcmd/SettingsWindow.swift diff --git a/.changeset/feed10f9.md b/.changeset/feed10f9.md new file mode 100644 index 0000000..9372e0d --- /dev/null +++ b/.changeset/feed10f9.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +Add a built-in Settings window for visual config (animations and live previews) with live apply. diff --git a/Sources/cmdcmd/Config.swift b/Sources/cmdcmd/Config.swift index c51c031..069aba4 100644 --- a/Sources/cmdcmd/Config.swift +++ b/Sources/cmdcmd/Config.swift @@ -29,6 +29,15 @@ struct Config: Codable { } } + static func save(_ config: Config) throws { + let dir = fileURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(config) + try data.write(to: fileURL, options: .atomic) + } + static func ensureExists() throws -> URL { let dir = fileURL.deletingLastPathComponent() try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) diff --git a/Sources/cmdcmd/Overlay.swift b/Sources/cmdcmd/Overlay.swift index 5ffdba7..3d0a348 100644 --- a/Sources/cmdcmd/Overlay.swift +++ b/Sources/cmdcmd/Overlay.swift @@ -20,7 +20,11 @@ final class Overlay { private var showIgnored: Bool = false private var dragState: DragState? private let tracker: SpaceTracker - private let config: Config + private var config: Config + + func updateConfig(_ config: Config) { + self.config = config + } private var displayKey: String = "main" private var activeScreen: NSScreen? diff --git a/Sources/cmdcmd/SettingsWindow.swift b/Sources/cmdcmd/SettingsWindow.swift new file mode 100644 index 0000000..55c6b88 --- /dev/null +++ b/Sources/cmdcmd/SettingsWindow.swift @@ -0,0 +1,110 @@ +import AppKit +import SwiftUI + +final class SettingsWindowController: NSWindowController { + private let model: SettingsModel + var onSave: ((Config) -> Void)? { + get { model.onSave } + set { model.onSave = newValue } + } + + init(config: Config) { + model = SettingsModel(config: config) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 460, height: 320), + styleMask: [.titled, .closable, .miniaturizable], + backing: .buffered, + defer: false + ) + window.title = "cmdcmd Settings" + window.isReleasedWhenClosed = false + window.center() + window.contentView = NSHostingView(rootView: SettingsRootView(model: model)) + super.init(window: window) + } + + required init?(coder: NSCoder) { nil } +} + +private final class SettingsModel: ObservableObject { + @Published var animations: Bool { didSet { save() } } + @Published var livePreviews: Bool { didSet { save() } } + private var base: Config + @Published var status: String = "" + var onSave: ((Config) -> Void)? + + init(config: Config) { + animations = config.animations + livePreviews = config.livePreviewsEnabled + base = config + } + + func save() { + var config = base + config.animations = animations + config.livePreviews = livePreviews + do { + try Config.save(config) + base = config + onSave?(config) + status = "Saved" + } catch { + status = "Save failed: \(error.localizedDescription)" + Log.write("settings save failed: \(error)") + } + } + + func openConfig() { + do { + let url = try Config.ensureExists() + NSWorkspace.shared.open(url) + } catch { + status = "Open failed: \(error.localizedDescription)" + Log.write("openConfig failed: \(error)") + } + } +} + +private struct SettingsRootView: View { + @ObservedObject var model: SettingsModel + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + Text("cmdcmd") + .font(.system(size: 22, weight: .semibold)) + + Toggle(isOn: $model.animations) { + VStack(alignment: .leading, spacing: 2) { + Text("Animations").font(.system(size: 13, weight: .medium)) + Text("Smooth open, pick, and peek transitions.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .toggleStyle(.switch) + + Toggle(isOn: $model.livePreviews) { + VStack(alignment: .leading, spacing: 2) { + Text("Live previews").font(.system(size: 13, weight: .medium)) + Text("Stream live frames per tile. Off uses static screenshots — lighter with many windows.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .toggleStyle(.switch) + + Spacer(minLength: 0) + + HStack(spacing: 10) { + Button("Open Config…") { model.openConfig() } + Spacer() + Text(model.status) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .padding(24) + .frame(minWidth: 420, minHeight: 280) + } +} diff --git a/Sources/cmdcmd/main.swift b/Sources/cmdcmd/main.swift index 05f0a87..414c428 100644 --- a/Sources/cmdcmd/main.swift +++ b/Sources/cmdcmd/main.swift @@ -26,8 +26,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { userDriverDelegate: nil ) + var settingsFactory: (() -> SettingsWindowController)? + private var settingsController: SettingsWindowController? + func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { let menu = NSMenu() + let settingsItem = NSMenuItem(title: "Settings…", action: #selector(openSettings), keyEquivalent: "") + settingsItem.target = self + menu.addItem(settingsItem) let openItem = NSMenuItem(title: "Open Config…", action: #selector(openConfig), keyEquivalent: "") openItem.target = self menu.addItem(openItem) @@ -39,6 +45,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return menu } + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + if !flag { openSettings() } + return true + } + + @objc func openSettings() { + let controller = settingsController ?? settingsFactory?() + settingsController = controller + controller?.showWindow(nil) + NSApp.activate(ignoringOtherApps: true) + } + @objc func openConfig() { do { let url = try Config.ensureExists() @@ -54,11 +72,20 @@ app.delegate = appDelegate app.finishLaunching() _ = try? Config.ensureExists() -let appConfig = Config.load() +var appConfig = Config.load() let tracker = SpaceTracker() let overlay = Overlay(tracker: tracker, config: appConfig) var trigger: AnyObject? +appDelegate.settingsFactory = { + let controller = SettingsWindowController(config: appConfig) + controller.onSave = { newConfig in + appConfig = newConfig + overlay.updateConfig(newConfig) + } + return controller +} + func startApp() { Task { _ = try? await SCShareableContent.current From 67c48130493ad7c127f017b30c697e0705596516 Mon Sep 17 00:00:00 2001 From: Peter Pistorius Date: Tue, 28 Apr 2026 22:25:23 +0200 Subject: [PATCH 2/3] Preserve config.json comments when saving via Settings UI Replace full re-encode with a small in-place patcher that scans the file as text, skipping line comments and strings, and replaces only the value of the requested top-level key. Inserts the key just before the root closing brace if it doesn't exist (carries a trailing comma onto the previous entry as needed). Comments, key order, and any hand-formatting in config.json now survive a Settings save round-trip. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/cmdcmd/Config.swift | 219 +++++++++++++++++++++++++++- Sources/cmdcmd/SettingsWindow.swift | 5 +- 2 files changed, 218 insertions(+), 6 deletions(-) diff --git a/Sources/cmdcmd/Config.swift b/Sources/cmdcmd/Config.swift index 069aba4..8351675 100644 --- a/Sources/cmdcmd/Config.swift +++ b/Sources/cmdcmd/Config.swift @@ -29,13 +29,222 @@ struct Config: Codable { } } - static func save(_ config: Config) throws { + /// Patch top-level keys in the existing config file in-place, preserving + /// comments, key order, and formatting. Each value is written as the + /// literal JSON token in `updates` (e.g. `"true"`, `"false"`, `"null"`). + /// If the file is missing or unreadable, falls back to writing the + /// template + the requested updates. + static func patchOnDisk(_ updates: [(key: String, value: String)]) throws { let dir = fileURL.deletingLastPathComponent() try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(config) - try data.write(to: fileURL, options: .atomic) + + let existing = (try? String(contentsOf: fileURL, encoding: .utf8)) + var text = existing ?? Self.template() + + for (key, valueLiteral) in updates { + text = patch(text: text, key: key, valueLiteral: valueLiteral) + } + try text.write(to: fileURL, atomically: true, encoding: .utf8) + } + + /// Replace the value of `key` at the root object's top level. Inserts + /// the key before the closing `}` if it doesn't exist. + static func patch(text: String, key: String, valueLiteral: String) -> String { + if let range = topLevelValueRange(in: text, key: key) { + return text.replacingCharacters(in: range, with: valueLiteral) + } + return insertTopLevelKey(text: text, key: key, valueLiteral: valueLiteral) + } + + /// Locate the value range for a top-level `"key": ` pair. + /// Range covers the value text only, excluding trailing comma/whitespace. + private static func topLevelValueRange(in text: String, key: String) -> Range? { + var i = text.startIndex + var depth = 0 + var inString = false + var escape = false + + while i < text.endIndex { + let c = text[i] + if escape { escape = false; i = text.index(after: i); continue } + if inString { + if c == "\\" { escape = true } + else if c == "\"" { inString = false } + i = text.index(after: i); continue + } + if c == "/" { + let n = text.index(after: i) + if n < text.endIndex && text[n] == "/" { + while i < text.endIndex && text[i] != "\n" { i = text.index(after: i) } + continue + } + } + if c == "\"" { + let keyOpen = text.index(after: i) + var j = keyOpen + var localEscape = false + while j < text.endIndex { + let cc = text[j] + if localEscape { localEscape = false; j = text.index(after: j); continue } + if cc == "\\" { localEscape = true; j = text.index(after: j); continue } + if cc == "\"" { break } + j = text.index(after: j) + } + let keyClose = j + let next = j < text.endIndex ? text.index(after: j) : j + + if depth == 1 { + let keyText = String(text[keyOpen.. Range? { + var i = start + while i < text.endIndex && text[i].isWhitespace { i = text.index(after: i) } + guard i < text.endIndex, text[i] == ":" else { return nil } + i = text.index(after: i) + while i < text.endIndex && text[i].isWhitespace { i = text.index(after: i) } + let valueStart = i + + var depth = 0 + var inString = false + var escape = false + while i < text.endIndex { + let c = text[i] + if escape { escape = false; i = text.index(after: i); continue } + if inString { + if c == "\\" { escape = true } + else if c == "\"" { inString = false } + i = text.index(after: i); continue + } + if c == "\"" { inString = true; i = text.index(after: i); continue } + if c == "/" { + let n = text.index(after: i) + if n < text.endIndex && text[n] == "/" { + while i < text.endIndex && text[i] != "\n" { i = text.index(after: i) } + continue + } + } + if c == "{" || c == "[" { depth += 1; i = text.index(after: i); continue } + if c == "}" || c == "]" { + if depth == 0 { break } + depth -= 1; i = text.index(after: i); continue + } + if depth == 0 && c == "," { break } + i = text.index(after: i) + } + var valueEnd = i + while valueEnd > valueStart { + let prev = text.index(before: valueEnd) + if text[prev].isWhitespace { valueEnd = prev } else { break } + } + return valueStart.. String { + var lastTopBraceClose: String.Index? + var i = text.startIndex + var depth = 0 + var inString = false + var escape = false + while i < text.endIndex { + let c = text[i] + if escape { escape = false; i = text.index(after: i); continue } + if inString { + if c == "\\" { escape = true } + else if c == "\"" { inString = false } + i = text.index(after: i); continue + } + if c == "/" { + let n = text.index(after: i) + if n < text.endIndex && text[n] == "/" { + while i < text.endIndex && text[i] != "\n" { i = text.index(after: i) } + continue + } + } + if c == "\"" { inString = true; i = text.index(after: i); continue } + if c == "{" || c == "[" { depth += 1 } + else if c == "}" || c == "]" { + if depth == 1 && c == "}" { lastTopBraceClose = i } + depth -= 1 + } + i = text.index(after: i) + } + guard let close = lastTopBraceClose else { return text } + + // Find indent of the closing brace's line. + var lineStart = close + while lineStart > text.startIndex { + let prev = text.index(before: lineStart) + if text[prev] == "\n" { break } + lineStart = prev + } + let indent = String(text[lineStart.. text.startIndex { + let prev = text.index(before: scan) + let pc = text[prev] + if pc == "\n" || pc == " " || pc == "\t" { + scan = prev; continue + } + // Walk back over a `// ...` line comment trailing on the same line. + if pc == "\n" { scan = prev; continue } + // Detect comment: walk to start of line, see if it begins with //. + var ls = prev + while ls > text.startIndex { + let p2 = text.index(before: ls) + if text[p2] == "\n" { break } + ls = p2 + } + let line = text[ls...prev] + if let slash = line.firstIndex(of: "/"), + text.index(after: slash) <= prev, + text[slash] == "/", text[text.index(after: slash)] == "/" { + scan = ls + continue + } + sawNonSpace = true + if pc != "," && pc != "{" { + needsTrailingCommaOnPrev = true + } + break + } + _ = sawNonSpace + + let entry = "\(indent) \"\(key)\": \(valueLiteral)" + var output = text + if needsTrailingCommaOnPrev { + // Insert "," at `scan` (right after the last non-ws/comment char). + output.insert(",", at: scan) + } + // Recompute close index after potential insert. + let newClose = needsTrailingCommaOnPrev ? output.index(close, offsetBy: 1) : close + var lineStart2 = newClose + while lineStart2 > output.startIndex { + let prev = output.index(before: lineStart2) + if output[prev] == "\n" { break } + lineStart2 = prev + } + output.insert(contentsOf: entry + "\n", at: lineStart2) + return output } static func ensureExists() throws -> URL { diff --git a/Sources/cmdcmd/SettingsWindow.swift b/Sources/cmdcmd/SettingsWindow.swift index 55c6b88..a985207 100644 --- a/Sources/cmdcmd/SettingsWindow.swift +++ b/Sources/cmdcmd/SettingsWindow.swift @@ -44,7 +44,10 @@ private final class SettingsModel: ObservableObject { config.animations = animations config.livePreviews = livePreviews do { - try Config.save(config) + try Config.patchOnDisk([ + ("animations", animations ? "true" : "false"), + ("livePreviews", livePreviews ? "true" : "false"), + ]) base = config onSave?(config) status = "Saved" From 832f2a42ede5eaddbf0b2606d9e8319700318db2 Mon Sep 17 00:00:00 2001 From: Peter Pistorius Date: Tue, 28 Apr 2026 22:27:59 +0200 Subject: [PATCH 3/3] Hide idle indicator when live previews are off With live previews disabled, tiles render a single screenshot and never emit further frames, so lastSignificantChangeAt stays at init time and every tile crosses the 2.5s idle threshold. Skip starting the activity timer entirely in that mode so the dot stays hidden. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/cmdcmd/Overlay.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/cmdcmd/Overlay.swift b/Sources/cmdcmd/Overlay.swift index 3d0a348..e381414 100644 --- a/Sources/cmdcmd/Overlay.swift +++ b/Sources/cmdcmd/Overlay.swift @@ -206,6 +206,7 @@ final class Overlay { private func startActivityTimer() { activityTimer?.invalidate() + guard config.livePreviewsEnabled else { return } activityTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: true) { [weak self] _ in guard let self else { return } let now = CFAbsoluteTimeGetCurrent()