From 8dc41bc36a1ad81d1cdd24ac99ea437d4df698ae Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 23 May 2026 23:07:41 +0900 Subject: [PATCH] Allow a custom (free) repository color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RepositoryColorChoice gains a `.custom(TintColor)` case alongside the named presets, with backward-compatible Codable: presets still encode as their bare case-name string, custom encodes as a keyed object. - TintColor is now Hashable so the choice stays Hashable. - The repository color picker adds a trailing iOS-style rainbow wheel: a solid rainbow dot until a color is picked, then the chosen color in the middle with a small gap to a thin rainbow rim. Tapping opens the system color panel via a small ColorPanelController (NSColorPanel target/action), seeded with the current color. - Tighten the swatch row spacing (8 → 6). - Expand RepositoryColorChoiceTests for preset + custom round-trips and legacy-string decoding. --- supacode/Domain/RepositoryColorChoice.swift | 103 +++++++++++++++--- supacode/Domain/TintColor.swift | 2 +- .../RepositoryAppearancePickerView.swift | 94 +++++++++++++++- .../RepositoryColorChoiceTests.swift | 61 +++++++++-- 4 files changed, 235 insertions(+), 25 deletions(-) diff --git a/supacode/Domain/RepositoryColorChoice.swift b/supacode/Domain/RepositoryColorChoice.swift index 0647d8bd..5707ea94 100644 --- a/supacode/Domain/RepositoryColorChoice.swift +++ b/supacode/Domain/RepositoryColorChoice.swift @@ -1,16 +1,20 @@ import SwiftUI -/// One of a fixed palette of system-provided colors a user can pin to a -/// repository to make it identifiable in the sidebar, shelf spine, and -/// canvas card title bar. The palette is intentionally closed (10 colors) -/// to align with macOS Finder's tag colors and to keep `repoColor` a -/// purely semantic system color — never a custom hex — per the project's -/// "system provided only" rule. +/// A color a user can pin to a repository to make it identifiable in the +/// sidebar, shelf spine, and canvas card title bar — and, with window +/// tinting enabled, to tint the nav / toolbar chrome. /// -/// Persistence: encoded as the raw `String` (case name). New cases are -/// safe to append; cases must never be renamed once shipped because user -/// JSON references them by name. -nonisolated enum RepositoryColorChoice: String, Codable, CaseIterable, Sendable, Hashable { +/// Either one of a fixed palette of system-provided colors (aligned with +/// macOS Finder's tag colors) or a free `.custom` color the user picks from +/// a color wheel. The named presets stay purely semantic system colors so +/// they adapt to light/dark mode; `.custom` is an explicit, user-driven +/// opt-out of that constraint (mirroring the window tint's custom color). +/// +/// Persistence: a preset encodes as its bare case-name `String` — identical +/// to the legacy `String`-rawValue representation, so existing user JSON +/// keeps decoding unchanged. `.custom` encodes as a keyed object so it is +/// visually distinct in the file and never collides with a preset name. +nonisolated enum RepositoryColorChoice: Codable, Sendable, Hashable { case red case orange case yellow @@ -21,6 +25,13 @@ nonisolated enum RepositoryColorChoice: String, Codable, CaseIterable, Sendable, case purple case pink case gray + case custom(TintColor) + + /// The named presets, in palette order. Excludes `.custom` (which carries + /// an associated value and is surfaced through its own picker affordance). + static let presets: [RepositoryColorChoice] = [ + .red, .orange, .yellow, .green, .mint, .cyan, .blue, .purple, .pink, .gray, + ] /// User-facing label for the color picker. var displayName: String { @@ -35,12 +46,13 @@ nonisolated enum RepositoryColorChoice: String, Codable, CaseIterable, Sendable, case .purple: "Purple" case .pink: "Pink" case .gray: "Gray" + case .custom: "Custom" } } - /// Resolved SwiftUI color. Only the bare named system colors are used - /// — never custom RGB — so the palette adapts to light/dark mode and - /// any future system tweaks. + /// Resolved SwiftUI color. Presets use the bare named system colors (never + /// custom RGB) so they adapt to light/dark mode; `.custom` resolves its + /// stored sRGB components. var color: Color { switch self { case .red: .red @@ -53,6 +65,71 @@ nonisolated enum RepositoryColorChoice: String, Codable, CaseIterable, Sendable, case .purple: .purple case .pink: .pink case .gray: .gray + case .custom(let tint): tint.color + } + } + + // MARK: - Codable + + /// Stable identifier for a preset — the legacy `String` raw value. `nil` + /// for `.custom`. + private var presetIdentifier: String? { + switch self { + case .red: "red" + case .orange: "orange" + case .yellow: "yellow" + case .green: "green" + case .mint: "mint" + case .cyan: "cyan" + case .blue: "blue" + case .purple: "purple" + case .pink: "pink" + case .gray: "gray" + case .custom: nil + } + } + + private init?(presetIdentifier: String) { + switch presetIdentifier { + case "red": self = .red + case "orange": self = .orange + case "yellow": self = .yellow + case "green": self = .green + case "mint": self = .mint + case "cyan": self = .cyan + case "blue": self = .blue + case "purple": self = .purple + case "pink": self = .pink + case "gray": self = .gray + default: return nil + } + } + + private enum CodingKeys: String, CodingKey { + case custom + } + + init(from decoder: any Decoder) throws { + // Preset / legacy form: a bare case-name string. + if let single = try? decoder.singleValueContainer(), + let name = try? single.decode(String.self), + let preset = Self(presetIdentifier: name) + { + self = preset + return + } + // Custom form: { "custom": }. + let container = try decoder.container(keyedBy: CodingKeys.self) + self = .custom(try container.decode(TintColor.self, forKey: .custom)) + } + + func encode(to encoder: any Encoder) throws { + if let presetIdentifier { + var container = encoder.singleValueContainer() + try container.encode(presetIdentifier) + } else if case .custom(let tint) = self { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(tint, forKey: .custom) } } } diff --git a/supacode/Domain/TintColor.swift b/supacode/Domain/TintColor.swift index d61f0f09..07c9c377 100644 --- a/supacode/Domain/TintColor.swift +++ b/supacode/Domain/TintColor.swift @@ -9,7 +9,7 @@ import SwiftUI /// meaning). The project's "system colors only" rule is deliberately /// relaxed here: the custom tint is an explicit, user-driven free color /// choice, unlike the closed `RepositoryColorChoice` palette. -nonisolated struct TintColor: Codable, Equatable, Sendable { +nonisolated struct TintColor: Codable, Equatable, Hashable, Sendable { var red: Double var green: Double var blue: Double diff --git a/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift b/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift index f4d792ca..c894881c 100644 --- a/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift +++ b/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift @@ -19,6 +19,10 @@ struct RepositoryAppearancePickerView: View { @State private var isSymbolPickerPresented = false @State private var isHoveringIconTile = false + /// Retains the AppKit controller that drives the shared color panel for the + /// custom-color swatch. `NSColorPanel` keeps its target weakly, so this must + /// outlive each presentation — `@State` ties it to the view's lifetime. + @State private var colorPanel = ColorPanelController() private let previewSize: CGFloat = 40 /// Diameter of the colored swatch itself. @@ -206,10 +210,11 @@ struct RepositoryAppearancePickerView: View { Text("Color") .font(.headline) .frame(width: previewSize, alignment: .center) - HStack(spacing: 8) { - ForEach(RepositoryColorChoice.allCases, id: \.self) { choice in + HStack(spacing: 6) { + ForEach(RepositoryColorChoice.presets, id: \.self) { choice in colorSwatch(for: choice) } + customColorSwatch noColorSwatch Spacer(minLength: 0) } @@ -242,6 +247,67 @@ struct RepositoryAppearancePickerView: View { .buttonStyle(.plain) } + /// Trailing swatch for a free, user-picked color, styled like iOS's color + /// picker: a rainbow ring (the "any color" affordance) around the chosen + /// color. macOS has no built-in style for this, so the wheel is hand-drawn; + /// tapping opens the system color panel via `ColorPanelController`, which + /// streams the picked color back through `setAppearanceColor`. + @ViewBuilder + private var customColorSwatch: some View { + let isSelected = isCustomColorSelected + Button { + colorPanel.onColorChange = { newColor in + store.send(.setAppearanceColor(.custom(TintColor(newColor)))) + } + colorPanel.present(initialColor: currentCustomColor) + } label: { + swatchSlot(isSelected: isSelected) { + customColorWheel + } + .help("Custom color — pick any color") + .accessibilityLabel("Custom color") + .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton) + } + .buttonStyle(.plain) + } + + /// A solid rainbow dot when no custom color is set (the "pick any color" + /// affordance); once a custom color is chosen, that color sits in the middle + /// with a small gap to a thin rainbow rim. + @ViewBuilder + private var customColorWheel: some View { + let rainbow = AngularGradient( + colors: [.red, .orange, .yellow, .green, .cyan, .blue, .purple, .pink, .red], + center: .center + ) + if case .custom(let tint) = store.appearance.color { + ZStack { + Circle() + .strokeBorder(rainbow, lineWidth: 2) + .frame(width: swatchDotSize, height: swatchDotSize) + Circle() + .fill(tint.color) + .frame(width: swatchDotSize - 8, height: swatchDotSize - 8) + } + } else { + Circle() + .fill(rainbow) + .frame(width: swatchDotSize, height: swatchDotSize) + } + } + + private var isCustomColorSelected: Bool { + if case .custom = store.appearance.color { return true } + return false + } + + /// Seeds the system color panel: the current custom color, or — for a repo + /// that has never used a custom color — the currently selected preset color, + /// falling back to the accent tint when no color is set at all. + private var currentCustomColor: Color { + store.appearance.color?.color ?? .accentColor + } + @ViewBuilder private var noColorSwatch: some View { let isSelected = store.appearance.color == nil @@ -352,3 +418,27 @@ struct RepositoryAppearancePickerView: View { } } } + +/// Thin AppKit controller that opens the shared `NSColorPanel` for the custom +/// color swatch and forwards live color changes back to SwiftUI. macOS has no +/// SwiftUI API to present the color panel from an arbitrary control, so this +/// drives it directly. `NSColorPanel` holds its target weakly, so the owner +/// must keep this controller alive (see `RepositoryAppearancePickerView`). +@MainActor +private final class ColorPanelController: NSObject { + /// Invoked continuously while the user adjusts the panel's color. + var onColorChange: ((Color) -> Void)? + + func present(initialColor: Color) { + let panel = NSColorPanel.shared + panel.showsAlpha = false + panel.color = NSColor(initialColor) + panel.setTarget(self) + panel.setAction(#selector(colorDidChange(_:))) + panel.makeKeyAndOrderFront(nil) + } + + @objc private func colorDidChange(_ sender: NSColorPanel) { + onColorChange?(Color(nsColor: sender.color)) + } +} diff --git a/supacodeTests/RepositoryColorChoiceTests.swift b/supacodeTests/RepositoryColorChoiceTests.swift index 631052ed..965932e6 100644 --- a/supacodeTests/RepositoryColorChoiceTests.swift +++ b/supacodeTests/RepositoryColorChoiceTests.swift @@ -6,16 +6,21 @@ import Testing struct RepositoryColorChoiceTests { @Test func paletteHasTenSystemColors() { // The fixed palette is part of the persistence contract: once - // shipped, removing or renaming a case would break user JSON. This + // shipped, removing or renaming a preset would break user JSON. This // test pins the count so an accidental rename or removal trips a // failure before release. - #expect(RepositoryColorChoice.allCases.count == 10) + #expect(RepositoryColorChoice.presets.count == 10) } - @Test func paletteCasesAreStable() { - // Raw values are written to JSON; reordering allCases is fine but - // case names are forever. Pin them. - let names = RepositoryColorChoice.allCases.map(\.rawValue).sorted() + @Test func presetCaseNamesAreStable() throws { + // Presets encode as their bare case-name string (the legacy raw value); + // reordering `presets` is fine but case names are forever. Pin them by + // checking the encoded string for each preset. + let encoder = JSONEncoder() + let names = try RepositoryColorChoice.presets.map { choice -> String in + try JSONDecoder().decode(String.self, from: encoder.encode(choice)) + } + .sorted() #expect( names == [ "blue", @@ -32,10 +37,10 @@ struct RepositoryColorChoiceTests { ) } - @Test func codableRoundTrip() throws { + @Test func presetCodableRoundTrip() throws { let encoder = JSONEncoder() let decoder = JSONDecoder() - for choice in RepositoryColorChoice.allCases { + for choice in RepositoryColorChoice.presets { let data = try encoder.encode(choice) let decoded = try decoder.decode(RepositoryColorChoice.self, from: data) #expect(decoded == choice) @@ -43,8 +48,46 @@ struct RepositoryColorChoiceTests { } @Test func displayNameNonEmpty() { - for choice in RepositoryColorChoice.allCases { + for choice in RepositoryColorChoice.presets + [.custom(.default)] { #expect(!choice.displayName.isEmpty) } } + + // MARK: - Custom color + + @Test func customDisplayNameIsCustom() { + #expect(RepositoryColorChoice.custom(.default).displayName == "Custom") + } + + @Test func legacyStringDecodesToPreset() throws { + let data = Data("\"green\"".utf8) + let decoded = try JSONDecoder().decode(RepositoryColorChoice.self, from: data) + #expect(decoded == .green) + } + + @Test func customRoundTrips() throws { + let original = RepositoryColorChoice.custom(TintColor(red: 0.1, green: 0.2, blue: 0.3)) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(RepositoryColorChoice.self, from: data) + #expect(decoded == original) + } + + @Test func customEncodesAsObjectNotABareString() throws { + let data = try JSONEncoder().encode(RepositoryColorChoice.custom(.default)) + // A custom color must not be mistaken for a preset case-name string. + #expect((try? JSONDecoder().decode(String.self, from: data)) == nil) + } + + @Test func repositoryAppearanceRoundTripsWithCustomColor() throws { + let appearance = RepositoryAppearance(color: .custom(TintColor(red: 0.5, green: 0.6, blue: 0.7))) + let data = try JSONEncoder().encode(appearance) + let decoded = try JSONDecoder().decode(RepositoryAppearance.self, from: data) + #expect(decoded == appearance) + } + + @Test func legacyRepositoryAppearanceWithStringColorStillDecodes() throws { + let data = Data(#"{"color":"red"}"#.utf8) + let decoded = try JSONDecoder().decode(RepositoryAppearance.self, from: data) + #expect(decoded.color == .red) + } } -- 2.51.2