diff --git a/ora/Common/Constants/Theme.swift b/ora/Common/Constants/Theme.swift index 9a4a1260..a4746d47 100644 --- a/ora/Common/Constants/Theme.swift +++ b/ora/Common/Constants/Theme.swift @@ -1,20 +1,99 @@ import AppKit import SwiftUI -// swiftlint:disable identifier_name +extension Notification.Name { + static let colorThemeChanged = Notification.Name("colorThemeChanged") + static let customColorChanged = Notification.Name("customColorChanged") +} + +struct ThemeConstants { + static let colorTransitionDuration: Double = 0.3 + static let colorThemeUserDefaultsKey = "ColorTheme" + static let customColorLightKey = "CustomColorLight" + static let customColorDarkKey = "CustomColorDark" +} + +enum ColorTheme: String, CaseIterable, Identifiable { + case orange = "Orange" + case blue = "Blue" + case green = "Green" + case purple = "Purple" + case red = "Red" + case teal = "Teal" + case gray = "Gray" + case custom = "Custom" + + var id: String { rawValue } + + var primaryLight: Color { + switch self { + case .orange: return Color(hex: "f3e5d6") + case .blue: return Color(hex: "d6e5f3") + case .green: return Color(hex: "d6f3e5") + case .purple: return Color(hex: "e5d6f3") + case .red: return Color(hex: "f3d6d6") + case .teal: return Color(hex: "d6f3f0") + case .gray: return Color(hex: "e5e5e5") + case .custom: + // Load custom color from UserDefaults and soften it for light mode + if let hexString = UserDefaults.standard.string(forKey: ThemeConstants.customColorLightKey) { + let originalColor = Color(hex: hexString) + // Soften the color: increase brightness, reduce saturation + return originalColor.adjusted(brightness: 1.3, saturation: 0.6) + } + return Color(hex: "f3e5d6") // fallback to orange + } + } + + var primaryDark: Color { + switch self { + case .orange: return Color(hex: "63411D") + case .blue: return Color(hex: "1D4163") + case .green: return Color(hex: "1D6341") + case .purple: return Color(hex: "411D63") + case .red: return Color(hex: "631D1D") + case .teal: return Color(hex: "1D6360") + case .gray: return Color(hex: "414141") + case .custom: + // Load custom color from UserDefaults + if let hexString = UserDefaults.standard.string(forKey: ThemeConstants.customColorDarkKey) { + return Color(hex: hexString) + } + return Color(hex: "63411D") // fallback to orange + } + } + + /// Returns the appropriate foreground color for the primary light background + var primaryLightForeground: Color { + primaryLight.adaptiveForeground + } + + /// Returns the appropriate foreground color for the primary dark background + var primaryDarkForeground: Color { + primaryDark.adaptiveForeground + } +} + struct Theme: Equatable { let colorScheme: ColorScheme + let colorTheme: ColorTheme + let updateTrigger: Int var primary: Color { - Color(hex: "f3e5d6") + colorTheme.primaryLight } var primaryDark: Color { - Color(hex: "63411D") + colorTheme.primaryDark } var accent: Color { - Color(hex: "FF5F57") + if colorTheme == .custom { + // Use a subtle version of the custom color as accent + let customColor = colorTheme.primaryLight + return customColor.adjusted(brightness: 1.1, saturation: 0.9) + } + return Color(hex: "FF5F57") } var background: Color { @@ -22,7 +101,7 @@ struct Theme: Equatable { } var foreground: Color { - colorScheme == .dark ? .white : .black + background.adaptiveForeground } var subtleWindowBackgroundColor: Color { @@ -77,6 +156,28 @@ struct Theme: Equatable { Color(hex: "#93DA97") } + // MARK: - Adaptive Foreground Colors + + /// Foreground color that adapts to the solid window background + var solidWindowForeground: Color { + solidWindowBackgroundColor.adaptiveForeground + } + + /// Foreground color that adapts to the inverted solid window background + var invertedSolidWindowForeground: Color { + invertedSolidWindowBackgroundColor.adaptiveForeground + } + + /// Foreground color that adapts to the active tab background + var activeTabForeground: Color { + activeTabBackground.adaptiveForeground + } + + /// Foreground color that adapts to the muted background + var mutedBackgroundForeground: Color { + mutedBackground.adaptiveForeground + } + var warning: Color { Color(hex: "#FFBF78") } @@ -110,7 +211,7 @@ struct Theme: Equatable { Color(hex: "#FF4500") } - var x: Color { + var xPlatform: Color { colorScheme == .dark ? .white : .black } @@ -128,7 +229,7 @@ struct Theme: Equatable { } private struct ThemeKey: EnvironmentKey { - static let defaultValue = Theme(colorScheme: .light) // fallback + static let defaultValue = Theme(colorScheme: .light, colorTheme: .orange, updateTrigger: 0) // fallback } extension EnvironmentValues { @@ -140,8 +241,33 @@ extension EnvironmentValues { struct ThemeProvider: ViewModifier { @Environment(\.colorScheme) private var colorScheme + @State private var colorTheme: ColorTheme = { + // Load initial value synchronously + let saved = UserDefaults.standard.string(forKey: ThemeConstants.colorThemeUserDefaultsKey) + return ColorTheme(rawValue: saved ?? "") ?? .orange + }() + @State private var customColorUpdateTrigger = 0 func body(content: Content) -> some View { - content.environment(\.theme, Theme(colorScheme: colorScheme)) + content + .environment( + \.theme, + Theme(colorScheme: colorScheme, colorTheme: colorTheme, updateTrigger: customColorUpdateTrigger) + ) + .onReceive(NotificationCenter.default.publisher(for: .colorThemeChanged)) { notification in + if let newTheme = notification.object as? ColorTheme { + withAnimation(.easeInOut(duration: ThemeConstants.colorTransitionDuration)) { + colorTheme = newTheme + } + } + } + .onReceive(NotificationCenter.default.publisher(for: .customColorChanged)) { _ in + // Trigger theme update when custom colors change + if colorTheme == .custom { + withAnimation(.easeInOut(duration: ThemeConstants.colorTransitionDuration)) { + customColorUpdateTrigger += 1 + } + } + } } } diff --git a/ora/Common/Extensions/Color+Hex.swift b/ora/Common/Extensions/Color+Hex.swift index e78efc68..8d422a0e 100644 --- a/ora/Common/Extensions/Color+Hex.swift +++ b/ora/Common/Extensions/Color+Hex.swift @@ -1,7 +1,7 @@ import SwiftUI -// swiftlint:disable identifier_name extension Color { + // swiftlint:disable identifier_name init(hex: String) { let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) var int: UInt64 = 0 @@ -26,6 +26,7 @@ extension Color { opacity: Double(a) / 255 ) } + // swiftlint:enable identifier_name /// Converts the Color to a hex string in `#RRGGBB` or `#RRGGBBAA` format func toHex(includeAlpha: Bool = false) -> String? { @@ -33,14 +34,71 @@ extension Color { guard let rgbColor = nsColor.usingColorSpace(.sRGB) else { return nil } - + // swiftlint:disable identifier_name let r = Int(rgbColor.redComponent * 255) let g = Int(rgbColor.greenComponent * 255) let b = Int(rgbColor.blueComponent * 255) let a = Int(rgbColor.alphaComponent * 255) + // swiftlint:enable identifier_name return includeAlpha ? String(format: "#%02X%02X%02X%02X", r, g, b, a) : String(format: "#%02X%02X%02X", r, g, b) } + + /// Adjusts the brightness and saturation of a color + func adjusted(brightness: Double = 1.0, saturation: Double = 1.0) -> Color { + let nsColor = NSColor(self) + guard let rgbColor = nsColor.usingColorSpace(.sRGB) else { + return self + } + // swiftlint:disable identifier_name + var h: CGFloat = 0 + var s: CGFloat = 0 + var b: CGFloat = 0 + var a: CGFloat = 0 + // swiftlint:enable identifier_name + rgbColor.getHue(&h, saturation: &s, brightness: &b, alpha: &a) + + return Color( + hue: Double(h), + saturation: min(1.0, Double(s) * saturation), + brightness: min(1.0, Double(b) * brightness) + ).opacity(Double(a)) + } + + /// Calculates the relative luminance of a color using the WCAG formula + var luminance: Double { + let nsColor = NSColor(self) + guard let rgbColor = nsColor.usingColorSpace(.sRGB) else { + return 0.5 // fallback to medium luminance + } + + // Convert to linear RGB + func linearize(_ component: Double) -> Double { + if component <= 0.03928 { + return component / 12.92 + } else { + return pow((component + 0.055) / 1.055, 2.4) + } + } + // swiftlint:disable identifier_name + let r = linearize(rgbColor.redComponent) + let g = linearize(rgbColor.greenComponent) + let b = linearize(rgbColor.blueComponent) + // swiftlint:enable identifier_name + + // WCAG luminance formula + return 0.2126 * r + 0.7152 * g + 0.0722 * b + } + + /// Returns an appropriate foreground color (black or white) based on the background's luminance + var adaptiveForeground: Color { + return luminance > 0.5 ? .black : .white + } + + /// Returns a contrasting foreground color with optional opacity + func contrastingForeground(opacity: Double = 1.0) -> Color { + return adaptiveForeground.opacity(opacity) + } } diff --git a/ora/Models/SearchEngine.swift b/ora/Models/SearchEngine.swift index d55b6713..15c6bc68 100644 --- a/ora/Models/SearchEngine.swift +++ b/ora/Models/SearchEngine.swift @@ -53,7 +53,7 @@ extension SearchEngine { return LauncherMain.Match( text: name, color: color, - foregroundColor: foregroundColor ?? .white, + foregroundColor: foregroundColor ?? color.adaptiveForeground, icon: icon, originalAlias: originalAlias, searchURL: searchURL, diff --git a/ora/Modules/Settings/Sections/GeneralSettingsView.swift b/ora/Modules/Settings/Sections/GeneralSettingsView.swift index f4863c5c..281fe45f 100644 --- a/ora/Modules/Settings/Sections/GeneralSettingsView.swift +++ b/ora/Modules/Settings/Sections/GeneralSettingsView.swift @@ -6,6 +6,14 @@ struct GeneralSettingsView: View { @EnvironmentObject var updateService: UpdateService @StateObject private var settings = SettingsStore.shared @Environment(\.theme) var theme + @State private var isColorPickerOpen = false + @State private var customColor: Color = { + // Load saved custom color or default to orange + if let hexString = UserDefaults.standard.string(forKey: ThemeConstants.customColorLightKey) { + return Color(hex: hexString) + } + return .orange + }() var body: some View { SettingsContainer(maxContentWidth: 760) { @@ -42,6 +50,122 @@ struct GeneralSettingsView: View { AppearanceSelector(selection: $appearanceManager.appearance) + // Color Scheme Selector + VStack(alignment: .leading, spacing: 8) { + Text("Color Scheme").foregroundStyle(.secondary) + + LazyVGrid( + columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 4), + spacing: 12 + ) { + ForEach(ColorTheme.allCases) { colorTheme in + let isSelected = appearanceManager.colorTheme == colorTheme + + if colorTheme == .custom { + // Custom color picker button with popover + Button { + isColorPickerOpen = true + } label: { + VStack(spacing: 6) { + ZStack { + // Show the actual selected custom color + Circle() + .fill(customColor) + .frame(width: 32, height: 32) + .shadow(color: .black.opacity(0.2), radius: 2, x: 0, y: 1) + + // Paint palette icon overlay with dynamic color + Image(systemName: "paintpalette.fill") + .font(.system(size: 12)) + .foregroundColor(customColor.adaptiveForeground) + .shadow(color: .black.opacity(0.3), radius: 1) + + // Always reserve space for border, show/hide with opacity + Circle() + .stroke(theme.foreground, lineWidth: 2) + .frame(width: 38, height: 38) + .opacity(isSelected ? 1 : 0) + } + + Text(colorTheme.rawValue) + .font(.caption) + .fontWeight(isSelected ? .semibold : .regular) + .foregroundColor(isSelected ? theme.foreground : .secondary) + } + } + .buttonStyle(.plain) + .popover(isPresented: $isColorPickerOpen, arrowEdge: .bottom) { + ColorPickerView(selectedColor: $customColor) { newColor in + // Apply the custom color live as user drags + customColor = newColor + + // Generate a darker version for dark mode + let lightHex = newColor.toHex() ?? "#f3e5d6" + let darkColor = newColor.adjusted(brightness: 0.3, saturation: 1.2) + let darkHex = darkColor.toHex() ?? "#63411D" + + // Store custom colors + UserDefaults.standard.set(lightHex, + forKey: ThemeConstants.customColorLightKey) + UserDefaults.standard.set(darkHex, + forKey: ThemeConstants.customColorDarkKey) + + // Force theme update + withAnimation( + .easeInOut(duration: ThemeConstants.colorTransitionDuration) + ) { + appearanceManager.colorTheme = .custom + // Post notifications to update theme + NotificationCenter.default.post( + name: .colorThemeChanged, + object: ColorTheme.custom + ) + NotificationCenter.default.post(name: .customColorChanged, object: nil) + } + } + } + } else { + // Regular color theme buttons + Button { + withAnimation(.easeInOut(duration: ThemeConstants.colorTransitionDuration)) { + appearanceManager.colorTheme = colorTheme + } + } label: { + VStack(spacing: 6) { + ZStack { + Circle() + .fill( + LinearGradient( + colors: [ + colorTheme.primaryLight, + colorTheme.primaryDark + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 32, height: 32) + .shadow(color: .black.opacity(0.2), radius: 2, x: 0, y: 1) + + // Always reserve space for border, show/hide with opacity + Circle() + .stroke(theme.foreground, lineWidth: 2) + .frame(width: 38, height: 38) + .opacity(isSelected ? 1 : 0) + } + + Text(colorTheme.rawValue) + .font(.caption) + .fontWeight(isSelected ? .semibold : .regular) + .foregroundColor(isSelected ? theme.foreground : .secondary) + } + } + .buttonStyle(.plain) + } + } + } + } + VStack(alignment: .leading, spacing: 12) { Text("Updates") .font(.headline) diff --git a/ora/Services/AppearanceManager.swift b/ora/Services/AppearanceManager.swift index 280ddbc1..b1eca74f 100644 --- a/ora/Services/AppearanceManager.swift +++ b/ora/Services/AppearanceManager.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppKit enum AppAppearance: String, CaseIterable, Identifiable { case system = "System" @@ -15,10 +16,21 @@ class AppearanceManager: ObservableObject { UserDefaults.standard.set(appearance.rawValue, forKey: "AppAppearance") } } + + @Published var colorTheme: ColorTheme { + didSet { + UserDefaults.standard.set(colorTheme.rawValue, forKey: ThemeConstants.colorThemeUserDefaultsKey) + NotificationCenter.default.post(name: .colorThemeChanged, object: colorTheme) + } + } init() { let saved = UserDefaults.standard.string(forKey: "AppAppearance") self.appearance = AppAppearance(rawValue: saved ?? "") ?? .system + + let savedColorTheme = UserDefaults.standard.string(forKey: ThemeConstants.colorThemeUserDefaultsKey) + self.colorTheme = ColorTheme(rawValue: savedColorTheme ?? "") ?? .orange + updateAppearance() } diff --git a/ora/Services/SearchEngineService.swift b/ora/Services/SearchEngineService.swift index 59ba9022..2616d3f7 100644 --- a/ora/Services/SearchEngineService.swift +++ b/ora/Services/SearchEngineService.swift @@ -56,7 +56,7 @@ class SearchEngineService: ObservableObject { aliases: ["chat", "chatgpt", "gpt", "cgpt", "openai", "cha"], searchURL: "https://chatgpt.com?q={query}", isAIChat: true, - foregroundColor: theme?.background ?? .black + foregroundColor: (theme?.foreground ?? .white).adaptiveForeground ), SearchEngine( name: "Google", @@ -74,7 +74,7 @@ class SearchEngineService: ObservableObject { aliases: ["grok", "gr", "gro"], searchURL: "https://grok.com?q={query}", isAIChat: true, - foregroundColor: theme?.background ?? .black + foregroundColor: (theme?.foreground ?? .white).adaptiveForeground ), SearchEngine( name: "Perplexity", @@ -107,7 +107,7 @@ class SearchEngineService: ObservableObject { aliases: ["x", "x.com", "twitter", "tw", "twtr", "twit", "twitt", "twitte"], searchURL: "https://twitter.com/search?q={query}", isAIChat: false, - foregroundColor: theme?.background ?? .black + foregroundColor: (theme?.foreground ?? .white).adaptiveForeground ) ] } @@ -122,7 +122,8 @@ class SearchEngineService: ObservableObject { icon: "", aliases: custom.aliases, searchURL: custom.searchURL, - isAIChat: false + isAIChat: false, + foregroundColor: (custom.faviconBackgroundColor ?? .blue).adaptiveForeground ) } diff --git a/ora/UI/ColorPicker/ColorPickerView.swift b/ora/UI/ColorPicker/ColorPickerView.swift new file mode 100644 index 00000000..426f92b3 --- /dev/null +++ b/ora/UI/ColorPicker/ColorPickerView.swift @@ -0,0 +1,275 @@ +import SwiftUI + +struct ColorPickerView: View { + @Binding var selectedColor: Color + let onColorSelected: (Color) -> Void + + @State private var hue: Double = 0.0 + @State private var saturation: Double = 1.0 + @State private var brightness: Double = 1.0 + @State private var hexInput: String = "" + + private let wheelSize: CGFloat = 200 + + var body: some View { + VStack(spacing: 20) { + // Title + Text("Color Picker") + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + + // Color Wheel + ZStack { + ColorWheelView( + hue: $hue, + saturation: $saturation, + brightness: $brightness, + size: wheelSize + ) + .frame(width: wheelSize, height: wheelSize) + } + + // Brightness Slider + VStack(spacing: 4) { + ZStack { + LinearGradient( + gradient: Gradient(colors: [ + Color(hue: hue, saturation: saturation, brightness: 0.35), + Color(hue: hue, saturation: saturation, brightness: 1) + ]), + startPoint: .leading, + endPoint: .trailing + ) + .frame(height: 12) + .cornerRadius(6) + + GeometryReader { geometry in + Circle() + .fill(Color.white) + .overlay(Circle().stroke(Color.gray, lineWidth: 1)) + .frame(width: 16, height: 16) + .position(x: CGFloat(nonLinearBrightnessToLinear(brightness)) * geometry.size.width, y: geometry.size.height / 2) + .gesture( + DragGesture() + .onChanged { value in + let linearValue = min(max(0, value.location.x / geometry.size.width), 1) + brightness = max(0.35, linearToNonLinearBrightness(linearValue)) + updateSelectedColor() + } + ) + } + } + .frame(height: 16) + } + + + // Color Values + HStack(spacing: 12) { + // Hex input + HStack(spacing: 4) { + Text("#") + .foregroundColor(.secondary) + TextField("", text: $hexInput) + .textFieldStyle(PlainTextFieldStyle()) + .frame(width: 70) + .onSubmit { + updateColorFromHex() + } + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.gray.opacity(0.1)) + .cornerRadius(6) + + Spacer() + + // RGB values + HStack(spacing: 8) { + ColorValueField(value: Int(hue * 360), label: "H") + ColorValueField(value: Int(saturation * 100), label: "S") + ColorValueField(value: Int(brightness * 100), label: "B") + } + } + } + .padding(20) + .frame(width: 300) + .background(Color(.windowBackgroundColor)) + .cornerRadius(16) + .shadow(color: Color.black.opacity(0.15), radius: 10, x: 0, y: 5) + .onAppear { + updateFromColor(selectedColor) + } + .onChange(of: hue) { _, _ in updateSelectedColor() } + .onChange(of: saturation) { _, _ in updateSelectedColor() } + .onChange(of: brightness) { _, _ in updateSelectedColor() } + } + + private func updateSelectedColor() { + selectedColor = Color(hue: hue, saturation: saturation, brightness: brightness) + hexInput = selectedColor.toHex() ?? "" + // Call the callback immediately for live updates + onColorSelected(selectedColor) + } + + private func updateFromColor(_ color: Color) { + let nsColor = NSColor(color) + guard let rgbColor = nsColor.usingColorSpace(.sRGB) else { return } + + var h: CGFloat = 0 + var s: CGFloat = 0 + var b: CGFloat = 0 + var a: CGFloat = 0 + rgbColor.getHue(&h, saturation: &s, brightness: &b, alpha: &a) + + hue = Double(h) + saturation = Double(s) + brightness = Double(b) + hexInput = color.toHex() ?? "" + } + + private func updateColorFromHex() { + let color = Color(hex: hexInput) + updateFromColor(color) + } + + // Convert non-linear brightness (0-1) to linear slider position (0-1) + // This gives more precision in the bright range (0.5-1.0) and compresses dark range (0-0.5) + private func nonLinearBrightnessToLinear(_ brightness: Double) -> Double { + // Use a power curve: y = x^2 for more precision in bright values + // This maps 0.5 brightness to 0.25 slider position, 0.8 to 0.64, etc. + return pow(brightness, 2.0) + } + + // Convert linear slider position (0-1) to non-linear brightness (0-1) + private func linearToNonLinearBrightness(_ linear: Double) -> Double { + // Inverse of the power curve: y = x^0.5 + // This maps 0.25 slider to 0.5 brightness, 0.64 to 0.8, etc. + return pow(linear, 0.5) + } + +} + +// Color Wheel Component +struct ColorWheelView: View { + @Binding var hue: Double + @Binding var saturation: Double + @Binding var brightness: Double + let size: CGFloat + + @State private var dragLocation: CGPoint = .zero + + var body: some View { + GeometryReader { geometry in + ZStack { + // Color wheel gradient + Circle() + .fill( + AngularGradient( + gradient: Gradient(colors: [ + Color(hue: 0.0, saturation: 1, brightness: 1), + Color(hue: 0.1, saturation: 1, brightness: 1), + Color(hue: 0.2, saturation: 1, brightness: 1), + Color(hue: 0.3, saturation: 1, brightness: 1), + Color(hue: 0.4, saturation: 1, brightness: 1), + Color(hue: 0.5, saturation: 1, brightness: 1), + Color(hue: 0.6, saturation: 1, brightness: 1), + Color(hue: 0.7, saturation: 1, brightness: 1), + Color(hue: 0.8, saturation: 1, brightness: 1), + Color(hue: 0.9, saturation: 1, brightness: 1), + Color(hue: 1.0, saturation: 1, brightness: 1) + ]), + center: .center + ) + ) + + // Saturation gradient from center (white to transparent) + Circle() + .fill( + RadialGradient( + gradient: Gradient(colors: [ + Color.white, + Color.white.opacity(0) + ]), + center: .center, + startRadius: 0, + endRadius: size / 2 + ) + ) + + // Brightness overlay (darken the whole wheel based on brightness) + Circle() + .fill(Color.black.opacity(1 - brightness)) + + // Selector circle + Circle() + .fill(Color(hue: hue, saturation: saturation, brightness: brightness)) + .frame(width: 20, height: 20) + .overlay( + Circle() + .stroke(Color.white, lineWidth: 2) + .shadow(radius: 2) + ) + .position(positionForColor()) + } + .frame(width: size, height: size) + .contentShape(Circle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + updateColor(at: value.location, in: geometry.size) + } + ) + } + } + + private func positionForColor() -> CGPoint { + let radius = size / 2 + let angle = hue * 2 * .pi + // Apply non-linear mapping to saturation for more precision in desaturated colors + let nonLinearDistance = pow(saturation, 0.5) * radius + + let x = radius + cos(angle) * nonLinearDistance + let y = radius + sin(angle) * nonLinearDistance // Changed from - to + to fix vertical inversion + + return CGPoint(x: x, y: y) + } + + private func updateColor(at location: CGPoint, in size: CGSize) { + let center = CGPoint(x: size.width / 2, y: size.height / 2) + let dx = location.x - center.x + let dy = location.y - center.y // Changed from center.y - location.y to fix vertical inversion + + let angle = atan2(dy, dx) + var normalizedAngle = angle / (2 * .pi) + if normalizedAngle < 0 { normalizedAngle += 1 } + + let distance = sqrt(dx * dx + dy * dy) + let maxRadius = min(size.width, size.height) / 2 + let normalizedDistance = min(distance / maxRadius, 1) + + hue = Double(normalizedAngle) + // Apply inverse non-linear mapping to saturation for more precision in desaturated colors + saturation = Double(pow(normalizedDistance, 2.0)) + } +} + + +// Small field for color values +struct ColorValueField: View { + let value: Int + let label: String + + var body: some View { + VStack(spacing: 2) { + Text("\(value)") + .font(.system(size: 11, weight: .medium)) + Text(label) + .font(.system(size: 9)) + .foregroundColor(.secondary) + } + .frame(width: 35) + .padding(.vertical, 4) + .background(Color.gray.opacity(0.1)) + .cornerRadius(6) + } +}