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
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
public import AppKit
public import Foundation
public import SwiftUI

/// The one accent for cmux-drawn chrome: the color that means "this is the
Expand All @@ -23,22 +24,46 @@ public struct CmuxAccentColor: Sendable, Hashable {

public let mode: CmuxAccentColorMode

/// Normalized `#RRGGBB` color drawn in ``CmuxAccentColorMode/custom``
/// mode. `nil` there falls back to cmux blue.
public let customHex: String?

public init(mode: CmuxAccentColorMode = .defaultValue) {
self.init(mode: mode, customHex: nil)
}

public init(mode: CmuxAccentColorMode, customHex: String?) {
self.mode = mode
self.customHex = CmuxAccentColorMode.normalizedCustomHex(customHex)
}

/// The accent stored in `defaults` (`app.accentColor`).
public static func stored(in defaults: UserDefaults = .standard) -> CmuxAccentColor {
CmuxAccentColor(
mode: .stored(in: defaults),
customHex: CmuxAccentColorMode.storedCustomHex(in: defaults)
)
}

/// cmux's own blue for a light or dark appearance.
public static func cmuxBlue(isDark: Bool) -> NSColor {
NSColor(
srgbRed: 0,
green: (isDark ? 145.0 : 136.0) / 255.0,
blue: 1.0,
alpha: 1.0
)
}

/// The accent for a light or dark appearance.
public func nsColor(isDark: Bool) -> NSColor {
switch mode {
case .cmux:
return NSColor(
srgbRed: 0,
green: (isDark ? 145.0 : 136.0) / 255.0,
blue: 1.0,
alpha: 1.0
)
return Self.cmuxBlue(isDark: isDark)
case .system:
return Self.systemAccent(isDark: isDark)
case .custom:
return customHex.flatMap { NSColor(hex: $0) } ?? Self.cmuxBlue(isDark: isDark)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@ public enum CmuxAccentColorMode: String, CaseIterable, Sendable {
case cmux
/// The macOS accent color from System Settings > Appearance.
case system
/// A user-chosen color stored under ``customHexUserDefaultsKey``.
case custom

/// UserDefaults key storing the raw value.
public static let userDefaultsKey = "appAccentColor"

/// UserDefaults key storing the `#RRGGBB` color for ``custom``.
public static let customHexUserDefaultsKey = "appAccentColorCustomHex"

/// Mode used when nothing valid is stored.
public static let defaultValue: CmuxAccentColorMode = .cmux

Expand All @@ -19,4 +24,43 @@ public enum CmuxAccentColorMode: String, CaseIterable, Sendable {
public static func stored(in defaults: UserDefaults = .standard) -> CmuxAccentColorMode {
CmuxAccentColorMode(rawValue: defaults.string(forKey: userDefaultsKey) ?? "") ?? defaultValue
}

/// Reads the stored custom color, or `nil` when it is missing or not a
/// valid `#RRGGBB` hex.
public static func storedCustomHex(in defaults: UserDefaults = .standard) -> String? {
normalizedCustomHex(defaults.string(forKey: customHexUserDefaultsKey))
}

/// Uppercased `#RRGGBB` form of a 6-digit hex, or `nil` when invalid.
public static func normalizedCustomHex(_ raw: String?) -> String? {
guard let raw else { return nil }
var digits = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if digits.hasPrefix("#") { digits.removeFirst() }
guard digits.count == 6, digits.allSatisfy(\.isHexDigit) else { return nil }
return "#" + digits.uppercased()
}

/// The `app.accentColor` value in cmux.json: `"cmux"`, `"system"`, or
/// the `#RRGGBB` color for ``custom``. `nil` for ``custom`` without a
/// valid color, which cmux.json cannot express.
public static func settingsFileValue(mode: CmuxAccentColorMode, customHex: String?) -> String? {
switch mode {
case .cmux, .system:
return mode.rawValue
case .custom:
return normalizedCustomHex(customHex)
}
}

/// Parses an `app.accentColor` value from cmux.json. A `#RRGGBB` hex
/// selects ``custom`` with that color. The bare `"custom"` is rejected
/// because it carries no color.
public static func parseSettingsFileValue(_ raw: String) -> (mode: CmuxAccentColorMode, customHex: String?)? {
if raw.hasPrefix("#") {
guard let hex = normalizedCustomHex(raw) else { return nil }
return (.custom, hex)
}
guard let mode = CmuxAccentColorMode(rawValue: raw), mode != .custom else { return nil }
return (mode, nil)
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
public import AppKit

/// Resolves ``CmuxAccentColor`` from `app.accentColor` once per change and
/// Resolves ``CmuxAccentColor`` from `app.accentColor` (mode and custom
/// color) once per change and
/// posts ``CmuxAccentColor/didChangeNotification`` (with itself as the
/// object) when the resolved accent changes: when the setting changes, or
/// when the macOS accent changes while the setting follows it.
Expand All @@ -15,11 +16,12 @@ public final class CmuxAccentColorObserver {
private let center: NotificationCenter
private var systemColorsToken: (any NSObjectProtocol)?
private var modeObservation: NSKeyValueObservation?
private var customHexObservation: NSKeyValueObservation?

public init(defaults: UserDefaults = .standard, center: NotificationCenter = .default) {
self.defaults = defaults
self.center = center
self.current = CmuxAccentColor(mode: .stored(in: defaults))
self.current = .stored(in: defaults)
}

public func startObserving() {
Expand All @@ -29,6 +31,11 @@ public final class CmuxAccentColorObserver {
self?.refresh()
}
}
customHexObservation = defaults.observe(\.appAccentColorCustomHex, options: []) { [weak self] _, _ in
Task { @MainActor [weak self] in
self?.refresh()
}
}
systemColorsToken = center.addObserver(
forName: NSColor.systemColorsDidChangeNotification,
object: nil,
Expand All @@ -44,7 +51,7 @@ public final class CmuxAccentColorObserver {
/// draws differently than before. Returns whether it posted.
@discardableResult
public func refresh() -> Bool {
let next = CmuxAccentColor(mode: .stored(in: defaults))
let next = CmuxAccentColor.stored(in: defaults)
guard next != current else { return false }
current = next
center.post(name: CmuxAccentColor.didChangeNotification, object: self)
Expand All @@ -58,4 +65,9 @@ extension UserDefaults {
@objc dynamic var appAccentColor: String? {
string(forKey: CmuxAccentColorMode.userDefaultsKey)
}

/// KVO hook for the custom accent hex, named after its UserDefaults key.
@objc dynamic var appAccentColorCustomHex: String? {
string(forKey: CmuxAccentColorMode.customHexUserDefaultsKey)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -548,10 +548,17 @@ enum CmuxEmbeddedConfigSchema {
"description": "App appearance mode."
},
"accentColor": {
"type": "string",
"enum": ["cmux", "system"],
"oneOf": [
{
"type": "string",
"enum": ["cmux", "system"]
},
{
"$ref": "#/$defs/colorHex"
}
],
"default": "cmux",
"description": "Accent for cmux-drawn chrome: the selected workspace, attention ring and pane flash, agent status, pane swap, canvas focus, and scroll markers. \"cmux\" uses cmux blue; \"system\" follows the macOS accent color and updates when it changes. workspaceColors.selectionColor and notifications.paneFlashColor still override their parts. Native controls always use the macOS accent."
"description": "Accent for cmux-drawn chrome: the selected workspace, attention ring and pane flash, agent status, pane swap, canvas focus, and scroll markers. \"cmux\" uses cmux blue; \"system\" follows the macOS accent color and updates when it changes; a \"#RRGGBB\" hex uses that color in light and dark mode. workspaceColors.selectionColor and notifications.paneFlashColor still override their parts. Native controls always use the macOS accent."
},
"appIcon": {
"type": "string",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,62 @@ import Testing
#expect(CmuxAccentColorMode.stored(in: defaults) == .cmux)
}

@Test func customModeDrawsTheStoredColorInBothSchemes() throws {
let suite = "CmuxAccentColorTests.custom.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }

defaults.set(CmuxAccentColorMode.custom.rawValue, forKey: CmuxAccentColorMode.userDefaultsKey)
defaults.set(" ff6a00 ", forKey: CmuxAccentColorMode.customHexUserDefaultsKey)
let accent = CmuxAccentColor.stored(in: defaults)
#expect(accent.mode == .custom)
#expect(accent.customHex == "#FF6A00")
#expect(rgbBytes(accent.nsColor(isDark: false)) == [255, 106, 0])
#expect(rgbBytes(accent.nsColor(isDark: true)) == [255, 106, 0])
}

@Test func customModeWithoutAValidColorFallsBackToCmuxBlue() {
for hex in [nil, "", "not-a-color", "#FF6A00AA"] {
let accent = CmuxAccentColor(mode: .custom, customHex: hex)
#expect(accent.customHex == nil)
#expect(rgbBytes(accent.nsColor(isDark: false)) == [0, 136, 255])
#expect(rgbBytes(accent.nsColor(isDark: true)) == [0, 145, 255])
}
}

@MainActor
@Test func observerPostsWhenTheCustomColorChanges() throws {
let suite = "CmuxAccentColorTests.customObserver.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
defaults.set(CmuxAccentColorMode.custom.rawValue, forKey: CmuxAccentColorMode.userDefaultsKey)
defaults.set("#FF6A00", forKey: CmuxAccentColorMode.customHexUserDefaultsKey)

let observer = CmuxAccentColorObserver(defaults: defaults, center: NotificationCenter())
#expect(observer.current.customHex == "#FF6A00")
defaults.set("#00FF00", forKey: CmuxAccentColorMode.customHexUserDefaultsKey)
#expect(observer.refresh() == true)
#expect(rgbBytes(observer.current.nsColor(isDark: true)) == [0, 255, 0])
#expect(observer.refresh() == false)
}

@Test func settingsFileValueRoundTripsEveryMode() throws {
#expect(CmuxAccentColorMode.settingsFileValue(mode: .cmux, customHex: "#FF6A00") == "cmux")
#expect(CmuxAccentColorMode.settingsFileValue(mode: .system, customHex: nil) == "system")
#expect(CmuxAccentColorMode.settingsFileValue(mode: .custom, customHex: "ff6a00") == "#FF6A00")
#expect(CmuxAccentColorMode.settingsFileValue(mode: .custom, customHex: nil) == nil)

for (mode, hex) in [(CmuxAccentColorMode.cmux, nil), (.system, nil), (.custom, "#FF6A00")] as [(CmuxAccentColorMode, String?)] {
let value = try #require(CmuxAccentColorMode.settingsFileValue(mode: mode, customHex: hex))
let parsed = try #require(CmuxAccentColorMode.parseSettingsFileValue(value))
#expect(parsed.mode == mode)
#expect(parsed.customHex == hex)
}
for invalid in ["custom", "purple", "#FF6A", "#FF6A00AA", ""] {
#expect(CmuxAccentColorMode.parseSettingsFileValue(invalid) == nil)
}
}

@Test func appearanceResolvesToMatchingScheme() throws {
let dark = try #require(NSAppearance(named: .darkAqua))
let aqua = try #require(NSAppearance(named: .aqua))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ public struct AppCatalogSection: SettingCatalogSection {
userDefaultsKey: CmuxAccentColorMode.userDefaultsKey
)

/// `#RRGGBB` color drawn when ``accentColor`` is
/// ``CmuxAccentColorMode/custom``. cmux.json sets it through a hex
/// `app.accentColor` value.
public let accentColorCustomHex = DefaultsKey<String>(
id: "app.accentColorCustomHex",
defaultValue: "",
userDefaultsKey: CmuxAccentColorMode.customHexUserDefaultsKey
)

public let language = DefaultsKey<AppLanguage>(
id: "app.language",
defaultValue: .system,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import CmuxFoundation

/// `app.accentColor` stores the mode's raw value (`"cmux"` or `"system"`).
/// `app.accentColor` stores the mode's raw value (`"cmux"`, `"system"` or `"custom"`).
extension CmuxAccentColorMode: SettingCodable {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import CmuxSettings
import Observation

/// Writes the Accent Color row's `app.accentColor` value to cmux.json, one
/// write at a time, always ending on the newest request.
///
/// cmux.json owns the value: after each write the host reloads the file,
/// which applies it to UserDefaults, and the row reads it back from there.
/// A color-well drag requests many values; while a write is in flight only
/// the newest request is kept, so writes never pile up and the last one wins.
/// ``requestedValue`` holds that newest request until every write finishes,
/// so the row shows the user's choice instead of an older value a reload
/// applied on the way.
@MainActor
@Observable
final class AccentColorSettingsFileWriter {
/// The newest requested value while writes are outstanding, else `nil`.
private(set) var requestedValue: String?

@ObservationIgnored private var queuedValue: String?
@ObservationIgnored private var drainTask: Task<Void, Never>?
@ObservationIgnored private let write: @MainActor (String) async throws -> Void
@ObservationIgnored private let didFail: @MainActor (Error) -> Void

/// - Parameters:
/// - write: Persists one value to cmux.json and applies it.
/// - didFail: Reports a failed write. Later requests still run.
init(
write: @escaping @MainActor (String) async throws -> Void,
didFail: @escaping @MainActor (Error) -> Void
) {
self.write = write
self.didFail = didFail
}

/// Queues `value`, replacing any queued value not yet written.
func request(_ value: String) {
requestedValue = value
queuedValue = value
guard drainTask == nil else { return }
drainTask = Task { [weak self] in
await self?.drain()
}
}

/// Waits until every requested value has been written.
func waitUntilIdle() async {
await drainTask?.value
}

private func drain() async {
while let value = queuedValue {
queuedValue = nil
do {
try await write(value)
} catch {
didFail(error)
}
}
requestedValue = nil
drainTask = nil
}
}

extension AccentColorSettingsFileWriter {
/// The cmux.json key the Accent Color row writes. It shares its id with
/// the UserDefaults-backed ``AppCatalogSection/accentColor``, which the
/// settings file store fills from this value.
static let settingsFileKey = JSONKey<String>(id: "app.accentColor", defaultValue: "cmux")
}
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,10 @@ public protocol SettingsHostActions: AnyObject {
/// Invalidates host-owned shortcut caches after Settings persists a shortcut change.
func notifyShortcutSettingsDidChange()

/// Reloads cmux.json after Settings writes it, so its values apply to
/// UserDefaults and live chrome before the file watcher notices.
func reloadSettingsFile()

/// Whether the host can register `shortcut` as its system-wide hotkey.
///
/// The macOS host applies Carbon conversion and app-reservation checks that
Expand Down Expand Up @@ -532,6 +536,9 @@ public extension SettingsHostActions {
/// Default no-op for hosts with no app-owned shortcut caches.
func notifyShortcutSettingsDidChange() {}

/// Default no-op for hosts without a settings file store.
func reloadSettingsFile() {}

/// Custom-sidebar defaults for package previews and tests without a live host.
func customSidebarNames() -> [String] { [] }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ extension SettingsWindowRoot {
slot(.app, proxy: proxy) {
AppSection(
defaultsStore: defaultsStore,
jsonStore: jsonStore,
catalog: catalog,
errorLog: runtime.errorLog,
hostActions: hostActions,
soundAgentCache: soundAgentCache
)
Expand Down
Loading
Loading