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..8351675 100644 --- a/Sources/cmdcmd/Config.swift +++ b/Sources/cmdcmd/Config.swift @@ -29,6 +29,224 @@ struct Config: Codable { } } + /// 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 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 { 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..e381414 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? @@ -202,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() diff --git a/Sources/cmdcmd/SettingsWindow.swift b/Sources/cmdcmd/SettingsWindow.swift new file mode 100644 index 0000000..a985207 --- /dev/null +++ b/Sources/cmdcmd/SettingsWindow.swift @@ -0,0 +1,113 @@ +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.patchOnDisk([ + ("animations", animations ? "true" : "false"), + ("livePreviews", livePreviews ? "true" : "false"), + ]) + 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