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
5 changes: 5 additions & 0 deletions .changeset/feed10f9.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
bump: patch
---

Add a built-in Settings window for visual config (animations and live previews) with live apply.
218 changes: 218 additions & 0 deletions Sources/cmdcmd/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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": <value>` pair.
/// Range covers the value text only, excluding trailing comma/whitespace.
private static func topLevelValueRange(in text: String, key: String) -> Range<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 == "\"" {
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..<keyClose])
if keyText == key,
let valueRange = valueRangeAfterColon(in: text, from: next) {
return valueRange
}
}
i = next
continue
}
if c == "{" || c == "[" { depth += 1 }
else if c == "}" || c == "]" { depth -= 1 }
i = text.index(after: i)
}
return nil
}

private static func valueRangeAfterColon(in text: String, from start: String.Index) -> Range<String.Index>? {
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..<valueEnd
}

/// Insert a new top-level key just before the root object's closing `}`.
private static func insertTopLevelKey(text: String, key: String, valueLiteral: String) -> 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..<close])

// Walk back past whitespace/comments to find the previous non-comment, non-whitespace char.
// If it's not `,` and not `{`, we need to add a comma to the previous entry.
var scan = close
var needsTrailingCommaOnPrev = false
var sawNonSpace = false
while scan > 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)
Expand Down
7 changes: 6 additions & 1 deletion Sources/cmdcmd/Overlay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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()
Expand Down
113 changes: 113 additions & 0 deletions Sources/cmdcmd/SettingsWindow.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading