native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
17 kB · 471 lines
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472import AppKitimport ComposableArchitectureimport SwiftUIimport UniformTypeIdentifiers
/// Inline section that drives a repository's icon and color choice./// Hosted at the top of `RepositorySettingsView`'s Form. The actual SF/// Symbol picker is presented as a sheet via `TabIconPickerView`,/// parameterised with `RepositoryIconPresets.presets` so the shared/// picker code surfaces repo-flavored vocabulary instead of the/// terminal one used by tab icons.////// All mutations go through `RepositorySettingsFeature` actions —/// never via direct `store.appearance.* = ...` writes — so the/// `store_state_mutation_in_views` SwiftLint rule stays clean and/// reducer tests can exercise every code path.struct RepositoryAppearancePickerView: View { @Bindable var store: StoreOf<RepositorySettingsFeature>
@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. private let swatchDotSize: CGFloat = 20 /// Diameter of the "selected" ring drawn around the swatch. The /// (ring − dot) / 2 gap (3pt) lives between the dot's edge and the /// ring's inside, matching macOS-native color pickers. private let swatchRingSize: CGFloat = 26 /// Outer slot every swatch occupies in the HStack so the row layout /// stays stable whether or not a swatch is selected (without a fixed /// slot the selection ring would expand the cell and shove its /// neighbors aside on hover/select). private let swatchSlotSize: CGFloat = 28 private let swatchRingLineWidth: CGFloat = 1.5
var body: some View { VStack(alignment: .leading, spacing: 10) { iconRow colorRow if let message = store.appearanceImportError { importErrorBanner(message: message) } } .sheet(isPresented: $store.isSymbolPickerPresented) { TabIconPickerView( initialIcon: currentSymbolName, defaultIcon: TabIconSource(systemSymbol: "folder.fill"), title: "Repository Icon", subtitle: """ Pick a preset or enter any SF Symbol name. SVG and SF Symbol icons are tinted \ with the repo color; bitmap formats keep their own colors. """, presets: RepositoryIconPresets.presets, suggestionsSection: { symbolName in AnyView( RepositorySymbolSuggestionsSection( phase: store.symbolSuggestions, onSuggest: { if case .loaded = store.symbolSuggestions { store.send(.regenerateSuggestionsTapped) } else { store.send(.suggestIconTapped) } }, onPick: { symbolName.wrappedValue = $0 } ) ) }, onApply: { applySymbolFromPicker($0) }, onCancel: { dismissSymbolPicker() } ) } }
// MARK: - Icon row
@ViewBuilder private var iconRow: some View { HStack(alignment: .center, spacing: 12) { iconMenu VStack(alignment: .leading, spacing: 4) { Text("Icon") .font(.headline) Text(iconHelpText) .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Spacer(minLength: 0) } }
/// Click target + menu trigger for the icon. The whole preview tile /// is the action surface — clicking opens a popover menu with the /// three options. Drops the trailing button cluster so narrow /// Settings windows don't truncate "Choose Symbol…" / "Clear Icon". /// Pattern matches macOS native flows (System Settings user picture, /// Finder "Get Info" icon). /// /// Implementation notes — `.buttonStyle(.plain)` (NOT /// `.menuStyle(.borderlessButton)`) is critical: the borderless /// menu style applies its own padding and a system tint that /// silently overrides the icon's `foregroundStyle`, so the user's /// chosen color stops appearing on the preview. Plain button style /// lets the label render exactly as authored. Hover detection, /// overlay border, pointer style, and tooltip all sit on the /// **outer** Menu — `.onHover` placed inside the Menu's label is /// swallowed by the menu's pointer interception. @ViewBuilder private var iconMenu: some View { Menu { Button("Choose Symbol…") { store.send(.chooseSymbolTapped) } Button("Suggest an Icon…") { store.send(.suggestIconTapped) } Button("Choose Image…") { presentImageImporter() } if store.appearance.icon != nil { Divider() Button("Clear Icon", role: .destructive) { store.send(.setAppearanceIcon(nil)) } } } label: { iconPreviewTile } .buttonStyle(.plain) .menuIndicator(.hidden) .fixedSize() .overlay { RoundedRectangle(cornerRadius: 8, style: .continuous) .stroke( Color.accentColor.opacity(isHoveringIconTile ? 0.65 : 0), lineWidth: 1.5 ) } .onHover { isHoveringIconTile = $0 } .pointerStyle(.link) .animation(.easeOut(duration: 0.12), value: isHoveringIconTile) .help("Click the icon preview to pick a symbol or import an image") }
/// Visual label for the menu trigger. The frame is locked to /// `previewSize × previewSize` so the row layout (and the title / /// description text alignment to its right) doesn't shift when the /// inner content swaps between the user's icon and the /// no-icon placeholder. `.contentShape` confines the click hit-test /// to the rounded rectangle so clicks just outside the visible /// tile don't open the menu. /// /// When no icon is set, a dashed border replaces the questionmark's /// silence with a "drop zone"-style affordance — the same pattern /// macOS uses for empty avatar / drag-target slots — so users see /// the tile is interactive even before they hover. @ViewBuilder private var iconPreviewTile: some View { let frame = RoundedRectangle(cornerRadius: 8, style: .continuous) let fill = Color.secondary.opacity(0.12) let hasIcon = store.appearance.icon != nil Group { if let icon = store.appearance.icon { RepositoryIconImage( icon: icon, repositoryRootURL: store.rootURL, tintColor: tintColor, size: 22 ) } else { Image(systemName: "questionmark") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(.tertiary) .accessibilityHidden(true) } } .frame(width: previewSize, height: previewSize) .background(fill, in: frame) .overlay { if !hasIcon { frame.stroke( Color.secondary.opacity(0.55), style: StrokeStyle(lineWidth: 1, dash: [3, 3]) ) } } .contentShape(.rect(cornerRadius: 8, style: .continuous)) .accessibilityLabel("Icon picker") }
private var iconHelpText: String { switch store.appearance.icon { case .userImage(let filename) where !filename.lowercased().hasSuffix(".svg"): return String(localized: "Bitmap icons keep their original colors and ignore the repo color.") case .userImage: return String(localized: "User-provided SVGs are tinted with the repo color.") case .detectedImage: return String(localized: "Detected automatically from this project's assets. It keeps its original colors.") case .sfSymbol: return String(localized: "SF Symbols pick up the repo color when one is set.") case .bundledAsset: return String(localized: "Bundled icons keep their original artwork.") case nil: return String(localized: "No icon set. Click the icon preview to pick a symbol or import an image.") } }
private var tintColor: Color { store.appearance.color?.color ?? .accentColor }
private var currentSymbolName: String? { if case .sfSymbol(let name) = store.appearance.icon { return name } return nil }
// MARK: - Color row
@ViewBuilder private var colorRow: some View { VStack(alignment: .leading, spacing: 6) { HStack(alignment: .center, spacing: 12) { Text("Color") .font(.headline) .frame(width: previewSize, alignment: .center) HStack(spacing: 6) { ForEach(RepositoryColorChoice.presets, id: \.self) { choice in colorSwatch(for: choice) } customColorSwatch noColorSwatch Spacer(minLength: 0) } } Text( "Tints the row in the sidebar, the shelf spine background, and the canvas card title bar." ) .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) .padding(.leading, previewSize + 12) } }
@ViewBuilder private func colorSwatch(for choice: RepositoryColorChoice) -> some View { let isSelected = store.appearance.color == choice Button { store.send(.setAppearanceColor(choice)) } label: { swatchSlot(isSelected: isSelected) { Circle() .fill(choice.color) .frame(width: swatchDotSize, height: swatchDotSize) } .help(choice.displayName) .accessibilityLabel(choice.displayName) .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton) } .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 Button { store.send(.setAppearanceColor(nil)) } label: { swatchSlot(isSelected: isSelected) { Circle() .stroke( Color.secondary.opacity(0.5), style: StrokeStyle(lineWidth: 1, dash: [2, 2]) ) .frame(width: swatchDotSize, height: swatchDotSize) .overlay { Image(systemName: "slash.circle") .font(.system(size: 11)) .foregroundStyle(.secondary) .accessibilityHidden(true) } } .help("No color") .accessibilityLabel("No color") .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton) } .buttonStyle(.plain) }
/// Centers the swatch content in a fixed-size slot and overlays a /// selection ring on top, sized larger than the swatch so a 3pt /// transparent gap separates the swatch's edge from the ring's /// inside — mirroring macOS-native color picker selection chrome. /// The slot bounds stay constant whether selected or not so swatch /// row layout doesn't reflow on selection change. @ViewBuilder private func swatchSlot<Content: View>( isSelected: Bool, @ViewBuilder content: () -> Content ) -> some View { ZStack { content() if isSelected { Circle() .stroke(Color.primary, lineWidth: swatchRingLineWidth) .frame(width: swatchRingSize, height: swatchRingSize) } } .frame(width: swatchSlotSize, height: swatchSlotSize) }
// MARK: - Error banner
@ViewBuilder private func importErrorBanner(message: String) -> some View { HStack(spacing: 6) { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.orange) .accessibilityHidden(true) Text(message) .font(.caption) .foregroundStyle(.primary) Spacer(minLength: 0) Button("Dismiss") { store.send(.dismissAppearanceImportError) } .buttonStyle(.plain) .font(.caption) .foregroundStyle(.secondary) } .padding(.vertical, 4) .padding(.horizontal, 8) .background( RoundedRectangle(cornerRadius: 6, style: .continuous) .fill(Color.orange.opacity(0.12)) ) }
// MARK: - Actions
private func applySymbolFromPicker(_ name: String?) { dismissSymbolPicker() if let name { store.send(.setAppearanceIcon(.sfSymbol(name))) } else { store.send(.setAppearanceIcon(nil)) } }
/// Dismissal goes through the reducer so an in-flight suggestion run /// is cancelled in one place. private func dismissSymbolPicker() { store.send(.symbolPickerDismissed) }
// Uses `NSOpenPanel` (rather than SwiftUI's `.fileImporter`) so the // panel can open at the repo's working directory — most users keep // their icon assets next to the project. Accepts any image UTType — // PNG / JPEG / WebP / HEIC / TIFF / GIF / etc. all flow through the // same `NSImage(contentsOf:)` render path. SVG is listed explicitly // because it's a structured-text format that doesn't always conform // to `.image` in older UTType conformance tables. Anything that fails // to decode falls back to the dashed placeholder at render time. private func presentImageImporter() { let panel = NSOpenPanel() panel.directoryURL = store.rootURL panel.allowedContentTypes = [.image, .svg] panel.canChooseDirectories = false panel.canChooseFiles = true panel.allowsMultipleSelection = false panel.prompt = String(localized: "Choose") panel.message = String(localized: "Choose an image to use as this repository's icon.")
panel.begin { response in guard response == .OK, let url = panel.url else { return } store.send(.importUserImage(url)) } }}
/// 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`).@MainActorprivate 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)) }}