diff --git a/CHANGELOG.md b/CHANGELOG.md index 55e9aa7..b0dcc65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ expect rough edges until 1.0. ## [Unreleased] ### Changed +- **The popover follows your Mac's light or dark mode.** It used to be dark all the time. On a + light Mac it is now light glass, with a darker green for text so it stays readable. Settings, + About and the other windows follow too, with the same glass or solid background. Selected chips now use dark text on the green + instead of white, which was hard to read. If you liked it dark, pick Dark in Settings › + Appearance. + - **The popover is shorter: settings below DPI collapse.** Polling rate, Lighting, Power Management, Buttons and Profiles are now compact cards of one row each, each showing its current value ("1000 Hz", "Static · 80%", the active profile). Click a row to open its @@ -39,6 +45,12 @@ expect rough edges until 1.0. it reads the answers, using a fake mouse instead of hardware. ### Added +- **Settings › Appearance.** Choose the theme for the popover and the app's windows: System + (the default), Dark, Light or Razer Green, a dark look with a faint green tint. The popover + also gets a thin green edge. Turn off "Glass effect" + for a solid background instead of the see-through one. It's easier to read over busy windows. + Changes apply straight away, even with the popover open. + - **The Cobra HyperSpeed works over Bluetooth.** Battery, DPI, brightness and lighting colour all work with the mouse in Bluetooth mode, so you no longer need the dongle or a cable. The battery history carries over too, because the mouse reports the same serial on diff --git a/Sources/MacRazer/AboutView.swift b/Sources/MacRazer/AboutView.swift index c4628eb..a7d185c 100644 --- a/Sources/MacRazer/AboutView.swift +++ b/Sources/MacRazer/AboutView.swift @@ -65,7 +65,7 @@ struct AboutView: View { Text("by").font(.system(size: 12)).foregroundStyle(.secondary) Link("SorcRR", destination: ProjectLinks.developer) .font(.system(size: 12, weight: .medium)) - .foregroundStyle(Color.razerGreen) + .foregroundStyle(Color.razerGreenText) } whatsNewRow } @@ -100,7 +100,7 @@ struct AboutView: View { Link("PR #2583", destination: ProjectLinks.cobraHyperSpeedPR) } .font(.system(size: 11.5, weight: .medium)) - .foregroundStyle(Color.razerGreen) + .foregroundStyle(Color.razerGreenText) } } @@ -113,7 +113,7 @@ struct AboutView: View { + "but you run it at your own risk.") Link("View the source", destination: ProjectLinks.repo) .font(.system(size: 11.5, weight: .medium)) - .foregroundStyle(Color.razerGreen) + .foregroundStyle(Color.razerGreenText) } } @@ -131,7 +131,7 @@ struct AboutView: View { Image(systemName: "chevron.right").font(.system(size: 8, weight: .semibold)) } .font(.system(size: 11.5, weight: .medium)) - .foregroundStyle(Color.razerGreen) + .foregroundStyle(Color.razerGreenText) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -177,7 +177,7 @@ struct AboutView: View { Label("Leave a Tip", systemImage: "heart") } .buttonStyle(.bordered) - .tint(.razerGreen) + .tint(.razerGreenText) Spacer(minLength: 0) Button("Done") { onDone() } .keyboardShortcut(.defaultAction) diff --git a/Sources/MacRazer/AboutWindowController.swift b/Sources/MacRazer/AboutWindowController.swift index 50cb0e2..33f3d12 100644 --- a/Sources/MacRazer/AboutWindowController.swift +++ b/Sources/MacRazer/AboutWindowController.swift @@ -31,8 +31,8 @@ final class AboutWindowController: AppWindowPresenter { w.title = "About MacRazer" w.styleMask = [.titled, .closable] w.isReleasedWhenClosed = false - // Match the popover and the other windows: the green accent needs a dark ground. - w.appearance = NSAppearance(named: .darkAqua) + // Themed like the popover per Settings › Appearance, kept up to date when it changes. + AppearanceSettings.shared.track(w) w.center() hosting = controller window = w diff --git a/Sources/MacRazer/AppDelegate.swift b/Sources/MacRazer/AppDelegate.swift index 699fea5..064cafc 100644 --- a/Sources/MacRazer/AppDelegate.swift +++ b/Sources/MacRazer/AppDelegate.swift @@ -25,6 +25,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, UNU model: DeviceTestModel(controller: controller, permissions: permissions)) private let updateChecker = UpdateChecker() private let launchAtLogin = LaunchAtLogin() + private let appearance = AppearanceSettings.shared + /// The solid colour or green tint behind the popover; see `PopoverBackdropView`. + private let popoverBackdrop = PopoverBackdropView() private lazy var aboutWindow = AboutWindowController() /// Closing it re-asks the auto-install gate. This window opens unfocused after an /// automatic install and can sit unnoticed for days, and while it is open it holds back @@ -103,13 +106,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, UNU popover.behavior = .transient popover.delegate = self popover.animates = false // open immediately, no slide animation - // Force a dark popover regardless of system appearance — the Razer-green accent and - // logo have poor contrast on the light-mode grey material; dark is also the gaming - // aesthetic and makes the green pop. - popover.appearance = NSAppearance(named: .darkAqua) + // Light or dark, glass or solid, per Settings › Appearance. Re-applied whenever the + // setting changes or macOS switches between light and dark, including while the + // popover is open. The popover's appearance is always set explicitly, never left nil: + // a nil one would follow the menu bar, which macOS tints from the wallpaper rather + // than the system's light or dark mode. + Publishers.CombineLatest3(appearance.$theme, appearance.$glass, + NSApp.publisher(for: \.effectiveAppearance)) + .sink { [weak self] theme, glass, system in + self?.applyAppearance(theme: theme, glass: glass, systemIsDark: system.isDark) + } + .store(in: &cancellables) let hosting = NSHostingController(rootView: PopoverView( controller: controller, remapper: remapper, updateChecker: updateChecker, - launchAtLogin: launchAtLogin, + launchAtLogin: launchAtLogin, appearance: appearance, onOpenSettings: { [weak self] in // Close first: the popover is transient and would dismiss itself the moment // the window takes focus, which looks like the click did two things. @@ -367,6 +377,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, UNU windowsBeforePopover = Set(NSApp.windows.filter(\.isVisible).map(ObjectIdentifier.init)) // Show first (instant), then kick off the refresh so the open never waits on IO. popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + // The frame view the backdrop lives in only exists once the popover is shown. + installPopoverBackdrop() // Activate, not just make key. This app has no Dock icon, so clicking the menu bar // item does not make it the active application, and a control in an inactive app's // window does not respond to the first click the way it looks like it should. Every @@ -510,6 +522,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, UNU @objc private func openDeviceTest() { present(deviceTestWindow) } @objc private func quit() { NSApplication.shared.terminate(nil) } + // MARK: - Appearance + + private func applyAppearance(theme: PopoverTheme, glass: Bool, systemIsDark: Bool) { + let look = PopoverLook.resolve(theme: theme, glass: glass, systemIsDark: systemIsDark) + popover.appearance = NSAppearance(named: look.appearanceName) + popoverBackdrop.fill = look.backdropColor + appearance.applyToWindows(look) + } + + /// Puts the backdrop into the popover's frame view, just under the content: above the + /// native material, so a solid colour hides it, and filling the arrow too. NSPopover + /// keeps its frame view between shows, so this only does work the first time. + private func installPopoverBackdrop() { + guard let content = popover.contentViewController?.view, + let frameView = content.superview, + popoverBackdrop.superview !== frameView else { return } + popoverBackdrop.frame = frameView.bounds + popoverBackdrop.autoresizingMask = [.width, .height] + frameView.addSubview(popoverBackdrop, positioned: .below, relativeTo: content) + } + // MARK: - NSPopoverDelegate func popoverDidShow(_ notification: Notification) { controller.setPopoverVisible(true) } diff --git a/Sources/MacRazer/ColorPickerPage.swift b/Sources/MacRazer/ColorPickerPage.swift index e3713f5..f00df44 100644 --- a/Sources/MacRazer/ColorPickerPage.swift +++ b/Sources/MacRazer/ColorPickerPage.swift @@ -49,7 +49,7 @@ struct ColorPickerPage: View { Text("Colour").font(.system(size: 16, weight: .semibold)) Spacer() Circle().fill(color).frame(width: 22, height: 22) - .overlay(Circle().strokeBorder(.white.opacity(0.35), lineWidth: 1)) + .overlay(Circle().strokeBorder(Color.primary.opacity(0.25), lineWidth: 1)) } wheel HStack(spacing: 8) { @@ -76,7 +76,7 @@ struct ColorPickerPage: View { gradient: Gradient(colors: [.white, .white.opacity(0)]), center: .center, startRadius: 0, endRadius: r))) .overlay(Circle().fill(.black.opacity(1 - bri))) - .overlay(Circle().strokeBorder(.white.opacity(0.15), lineWidth: 1)) + .overlay(Circle().strokeBorder(Color.primary.opacity(0.15), lineWidth: 1)) .frame(width: side, height: side) Circle().stroke(.white, lineWidth: 2) .frame(width: 15, height: 15) diff --git a/Sources/MacRazer/CrashReportView.swift b/Sources/MacRazer/CrashReportView.swift index 3ab16b6..65a0d33 100644 --- a/Sources/MacRazer/CrashReportView.swift +++ b/Sources/MacRazer/CrashReportView.swift @@ -78,6 +78,10 @@ struct CrashReportView: View { set: { model.comment = String($0.prefix(DeviceReportOutput.maxComment)) })) .font(.system(size: 12)) .frame(height: 54) + // The editor's own opaque background would sit as a flat box on a glass or tinted + // window; a faint fill like the cards keeps it readable on all of them. + .scrollContentBackground(.hidden) + .background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 6)) .overlay(alignment: .topLeading) { if model.comment.isEmpty { Text("What were you doing when it quit? (optional)") diff --git a/Sources/MacRazer/CrashReportWindowController.swift b/Sources/MacRazer/CrashReportWindowController.swift index 0e88ed2..e5229e9 100644 --- a/Sources/MacRazer/CrashReportWindowController.swift +++ b/Sources/MacRazer/CrashReportWindowController.swift @@ -60,8 +60,8 @@ final class CrashReportWindowController: NSObject, AppWindowPresenter, NSWindowD w.title = "MacRazer Crash Report" w.styleMask = [.titled, .closable] w.isReleasedWhenClosed = false - // Match the other windows: the green accent needs a dark ground. - w.appearance = NSAppearance(named: .darkAqua) + // Themed like the popover per Settings › Appearance, kept up to date when it changes. + AppearanceSettings.shared.track(w) w.delegate = self w.center() window = w diff --git a/Sources/MacRazer/DeviceTestView.swift b/Sources/MacRazer/DeviceTestView.swift index eab58f0..7ef92e6 100644 --- a/Sources/MacRazer/DeviceTestView.swift +++ b/Sources/MacRazer/DeviceTestView.swift @@ -53,7 +53,7 @@ struct DeviceTestView: View { private var intro: some View { VStack(alignment: .leading, spacing: 12) { if model.isKnownSupported { - tag("Fully supported", color: .razerGreen) + tag("Fully supported", color: .razerGreenText) title("Your \(model.deviceName) is already supported") note("The test checks it really works on your mouse and firmware. Reports like this keep the supported list honest, and catch it early if a firmware update breaks something.") } else { @@ -165,7 +165,7 @@ struct DeviceTestView: View { VStack(spacing: 4) { row("Yours", record.data?.original.map(String.init) ?? "No answer") row("Test, read back", "\(record.data?.test.map(String.init) ?? "–"), \(record.data?.readBack.map(String.init) ?? "–")") - row("Put back", record.data?.restored == true ? "Yes" : "No", color: record.data?.restored == true ? .razerGreen : .batteryMid) + row("Put back", record.data?.restored == true ? "Yes" : "No", color: record.data?.restored == true ? .razerGreenText : .batteryMid) if let stages = record.data?.stages { row("Stages", stages.map(String.init).joined(separator: " · ")) } if let max = record.data?.maxProbe { row("Highest it kept", String(max)) } } @@ -195,7 +195,7 @@ struct DeviceTestView: View { VStack(spacing: 4) { row("Yours", record.data?.original.map { "\($0) Hz" } ?? "No answer") row("Test, read back", "\(record.data?.test.map { "\($0) Hz" } ?? "–"), \(record.data?.readBack.map { "\($0) Hz" } ?? "–")") - row("Put back", record.data?.restored == true ? "Yes" : "No", color: record.data?.restored == true ? .razerGreen : .batteryMid) + row("Put back", record.data?.restored == true ? "Yes" : "No", color: record.data?.restored == true ? .razerGreenText : .batteryMid) } } outcomeRow("Polling rate", record.outcome) @@ -257,7 +257,7 @@ struct DeviceTestView: View { card { VStack(spacing: 4) { row(report.device.name, String(format: "0x%04X", report.device.productID) + (report.identify.data?.firmware.map { ", \($0)" } ?? "")) - if !verdict.passed.isEmpty { row("Worked", verdict.passed.joined(separator: ", "), color: .razerGreen) } + if !verdict.passed.isEmpty { row("Worked", verdict.passed.joined(separator: ", "), color: .razerGreenText) } if !verdict.failed.isEmpty { row("Didn't work", verdict.failed.joined(separator: ", "), color: .batteryMid) } if !verdict.notSupported.isEmpty { row("Not on this mouse", verdict.notSupported.joined(separator: ", ")) } if !verdict.skipped.isEmpty { row("Skipped", verdict.skipped.joined(separator: ", ")) } @@ -284,6 +284,10 @@ struct DeviceTestView: View { set: { model.comment = String($0.prefix(DeviceReportOutput.maxComment)) })) .font(.system(size: 12)) .frame(height: 54) + // The editor's own opaque background would sit as a flat box on a glass or tinted + // window; a faint fill like the cards keeps it readable on all of them. + .scrollContentBackground(.hidden) + .background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 6)) .overlay(alignment: .topLeading) { if model.comment.isEmpty { Text("Comments or feedback (optional)").font(.system(size: 12)).foregroundStyle(.tertiary) @@ -366,7 +370,7 @@ struct DeviceTestView: View { private func stepTitle(_ text: String, changes: Bool) -> some View { HStack(spacing: 8) { title(text) - tag(changes ? "Changes, then puts back" : "Reads only", color: changes ? .batteryMid : .razerGreen) + tag(changes ? "Changes, then puts back" : "Reads only", color: changes ? .batteryMid : .razerGreenText) } } @@ -403,7 +407,7 @@ struct DeviceTestView: View { private func outcomeRow(_ label: String, _ outcome: DeviceReport.Outcome) -> some View { let (text, color): (String, Color) = { switch outcome { - case .passed: return ("Works", .razerGreen) + case .passed: return ("Works", .razerGreenText) case .failed: return ("Didn't work", .batteryMid) case .notSupported: return ("Not on this mouse", .secondary) case .skipped: return ("Skipped", .secondary) diff --git a/Sources/MacRazer/DeviceTestWindowController.swift b/Sources/MacRazer/DeviceTestWindowController.swift index 9ee923c..13b0a1b 100644 --- a/Sources/MacRazer/DeviceTestWindowController.swift +++ b/Sources/MacRazer/DeviceTestWindowController.swift @@ -21,8 +21,8 @@ final class DeviceTestWindowController: NSObject, AppWindowPresenter, NSWindowDe let w = NSWindow(contentViewController: hosting) w.styleMask = [.titled, .closable] w.isReleasedWhenClosed = false - // Match the popover's forced-dark look, like the app's other windows. - w.appearance = NSAppearance(named: .darkAqua) + // Themed like the popover per Settings › Appearance, kept up to date when it changes. + AppearanceSettings.shared.track(w) w.delegate = self w.center() window = w diff --git a/Sources/MacRazer/PermissionsWindowController.swift b/Sources/MacRazer/PermissionsWindowController.swift index cfd096c..c676b05 100644 --- a/Sources/MacRazer/PermissionsWindowController.swift +++ b/Sources/MacRazer/PermissionsWindowController.swift @@ -29,8 +29,8 @@ final class PermissionsWindowController: AppWindowPresenter { w.title = "MacRazer Setup" w.styleMask = [.titled, .closable] w.isReleasedWhenClosed = false - // Match the popover's forced-dark look so the green accent reads correctly. - w.appearance = NSAppearance(named: .darkAqua) + // Themed like the popover per Settings › Appearance, kept up to date when it changes. + AppearanceSettings.shared.track(w) w.center() window = w } diff --git a/Sources/MacRazer/PopoverTheme.swift b/Sources/MacRazer/PopoverTheme.swift new file mode 100644 index 0000000..c32a5ad --- /dev/null +++ b/Sources/MacRazer/PopoverTheme.swift @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Part of MacRazer, a control app for Razer mice on macOS. See LICENSE and NOTICE.md. + +import AppKit +import Combine + +/// The theme picked in Settings › Appearance. +enum PopoverTheme: String, CaseIterable, Identifiable { + case system, dark, light, razerGreen + + var id: String { rawValue } + + var title: String { + switch self { + case .system: return "System" + case .dark: return "Dark" + case .light: return "Light" + case .razerGreen: return "Razer Green" + } + } +} + +/// What the popover and the app's windows actually look like once the theme, the glass switch +/// and the system's light or dark mode are combined. One case per mockup. +enum PopoverLook: Equatable, CaseIterable { + case lightGlass, darkGlass, greenGlass + case solidLight, solidDark, solidGreen + + /// Every combination in one place, so the popover, its arrow and the tests can't disagree. + /// Razer Green is always dark: a light green tint looks washed out. + static func resolve(theme: PopoverTheme, glass: Bool, systemIsDark: Bool) -> PopoverLook { + switch theme { + case .system: return systemIsDark ? (glass ? .darkGlass : .solidDark) : (glass ? .lightGlass : .solidLight) + case .dark: return glass ? .darkGlass : .solidDark + case .light: return glass ? .lightGlass : .solidLight + case .razerGreen: return glass ? .greenGlass : .solidGreen + } + } + + var isDark: Bool { + switch self { + case .lightGlass, .solidLight: return false + case .darkGlass, .greenGlass, .solidDark, .solidGreen: return true + } + } + + var isGlass: Bool { + switch self { + case .lightGlass, .darkGlass, .greenGlass: return true + case .solidLight, .solidDark, .solidGreen: return false + } + } + + /// Whether the thin green edge goes round the popover. + var hasGreenEdge: Bool { self == .greenGlass } + + /// What's painted over the popover's own material, arrow included. Nil leaves the native + /// glass as it is. Solid looks are opaque; the green glass is a faint tint. + var backdropColor: NSColor? { + switch self { + case .lightGlass, .darkGlass: return nil + case .greenGlass: return NSColor(hex: 0x103A14, alpha: 0.35) + case .solidLight: return NSColor(hex: 0xF5F5F7) + case .solidDark: return NSColor(hex: 0x1C1C1E) + case .solidGreen: return NSColor(hex: 0x0F1A11) + } + } + + var appearanceName: NSAppearance.Name { isDark ? .darkAqua : .aqua } +} + +/// The Appearance settings, stored in UserDefaults. Shared by the Settings window, which +/// edits them, and the app delegate, which applies them to the popover and the windows. +@MainActor +final class AppearanceSettings: ObservableObject { + static let themeKey = "appearance.theme" + static let glassKey = "appearance.glass" + + /// The app's one instance. The window controllers reach it here rather than having it + /// threaded through every initializer; tests make their own with a scratch defaults suite. + static let shared = AppearanceSettings() + + private let defaults: UserDefaults + + @Published var theme: PopoverTheme { + didSet { defaults.set(theme.rawValue, forKey: Self.themeKey) } + } + @Published var glass: Bool { + didSet { defaults.set(glass, forKey: Self.glassKey) } + } + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + // An unknown stored value (a theme removed in a later version) falls back to System + // rather than failing. + theme = defaults.string(forKey: Self.themeKey).flatMap(PopoverTheme.init(rawValue:)) ?? .system + glass = defaults.object(forKey: Self.glassKey) as? Bool ?? true + } + + func look(systemIsDark: Bool) -> PopoverLook { + PopoverLook.resolve(theme: theme, glass: glass, systemIsDark: systemIsDark) + } + + // MARK: Windows + + private let windows = NSHashTable.weakObjects() + /// The look last given to the windows, so a window opened later matches them. + private var windowLook: PopoverLook? + + /// Gives a window the current look (glass or solid, light, dark or green) and keeps it + /// updated when the setting or the system's light or dark mode changes. + func track(_ window: NSWindow) { + windows.add(window) + let look = windowLook ?? look(systemIsDark: NSApplication.shared.effectiveAppearance.isDark) + WindowBackdrop.apply(look, to: window) + } + + /// Re-applies a look to every tracked window. Takes the look rather than working it out + /// from `theme`, because the owner calls this from a `$theme` subscriber, which runs before + /// the property itself has changed. + func applyToWindows(_ look: PopoverLook) { + windowLook = look + for window in windows.allObjects { WindowBackdrop.apply(look, to: window) } + } +} + +/// Puts a `PopoverLook` on an ordinary window: the same glass, solid colour or green tint as +/// the popover, so Settings and the other windows match it. +/// +/// Like the popover's backdrop, the glass and the tint sit in the window's frame view, just +/// under the content, so they also cover the title bar. +@MainActor +enum WindowBackdrop { + private static let glassID = NSUserInterfaceItemIdentifier("MacRazer.windowGlass") + private static let tintID = NSUserInterfaceItemIdentifier("MacRazer.windowTint") + + static func apply(_ look: PopoverLook, to window: NSWindow) { + window.appearance = NSAppearance(named: look.appearanceName) + // The title bar shows whatever is behind it, so it matches the rest of the window + // instead of drawing its own grey strip. + window.titlebarAppearsTransparent = true + let (glass, tint) = layers(in: window) + if look.isGlass { + window.isOpaque = false + window.backgroundColor = .clear + glass?.isHidden = false + tint?.fill = look.backdropColor + } else { + window.isOpaque = true + window.backgroundColor = look.backdropColor + glass?.isHidden = true + tint?.fill = nil + } + } + + /// The window's glass and tint views, added the first time. + private static func layers(in window: NSWindow) -> (NSVisualEffectView?, PopoverBackdropView?) { + guard let content = window.contentView, let frameView = content.superview else { return (nil, nil) } + if let glass = frameView.subviews.first(where: { $0.identifier == glassID }) as? NSVisualEffectView, + let tint = frameView.subviews.first(where: { $0.identifier == tintID }) as? PopoverBackdropView { + return (glass, tint) + } + let glass = NSVisualEffectView(frame: frameView.bounds) + glass.identifier = glassID + // The popover's own material, so a window and the popover read as the same glass. + glass.material = .popover + glass.blendingMode = .behindWindow + // Stay glass when the window isn't key, rather than going flat grey. + glass.state = .active + glass.autoresizingMask = [.width, .height] + let tint = PopoverBackdropView(frame: frameView.bounds) + tint.identifier = tintID + tint.autoresizingMask = [.width, .height] + frameView.addSubview(glass, positioned: .below, relativeTo: content) + frameView.addSubview(tint, positioned: .below, relativeTo: content) + return (glass, tint) + } +} + +extension NSColor { + /// An sRGB colour from `0xRRGGBB`, the way the mockups and the docs write them. + convenience init(hex: UInt32, alpha: CGFloat = 1) { + self.init(srgbRed: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255, + blue: CGFloat(hex & 0xFF) / 255, alpha: alpha) + } +} + +extension NSAppearance { + var isDark: Bool { bestMatch(from: [.aqua, .darkAqua]) == .darkAqua } +} + +/// Paints the solid colour (or the green tint) behind the popover's content. `WindowBackdrop` +/// reuses it as the green tint on the app's windows. +/// +/// It sits in the popover's frame view rather than in SwiftUI, because the arrow is drawn by +/// that frame view, outside the content. A SwiftUI background would leave the arrow glass +/// while the body went solid. The frame view clips its subviews to the popover's outline, +/// arrow included, so filling the whole bounds fills exactly the popover. +final class PopoverBackdropView: NSView { + var fill: NSColor? { + didSet { isHidden = fill == nil; needsDisplay = true } + } + + override func draw(_ dirtyRect: NSRect) { + guard let fill else { return } + fill.setFill() + dirtyRect.fill(using: .sourceOver) + } + + /// Clicks go to the content, never to the backdrop. + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} diff --git a/Sources/MacRazer/PopoverView.swift b/Sources/MacRazer/PopoverView.swift index 32a9b4d..66114a2 100644 --- a/Sources/MacRazer/PopoverView.swift +++ b/Sources/MacRazer/PopoverView.swift @@ -10,14 +10,28 @@ private struct HeightKey: PreferenceKey { static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = max(value, nextValue()) } } +/// A colour with one value for light surfaces and one for dark, picked by whatever the view is +/// drawn in. The popover and windows can be either, depending on Settings › Appearance. +private func adaptive(light: NSColor, dark: NSColor) -> Color { + Color(nsColor: NSColor(name: nil) { $0.isDark ? dark : light }) +} + extension Color { - /// Razer brand green (#44D62C) — the single accent. Budget: logo, slider, active chip/segment. - static let razerGreen = Color(red: 0x44 / 255, green: 0xD6 / 255, blue: 0x2C / 255) - /// A brighter, more vivid green for the logo so it really pops on the dark background. - static let razerGreenBright = Color(red: 0.42, green: 1.0, blue: 0.25) - /// The Bluetooth chip's colour. System blue is too dark to read on the popover's dark - /// background. - static let bluetoothBlue = Color(red: 0.47, green: 0.72, blue: 1.0) + /// Razer brand green (#44D62C) — the single accent, as a fill: slider, active chip/segment, + /// switches. The same in light and dark. For green text or icons use `razerGreenText`. + static let razerGreen = Color(nsColor: NSColor(hex: 0x44D62C)) + /// Green for text and icons. The brand green is too light to read on a light background, + /// so light surfaces get a darker green (#23891A). + static let razerGreenText = adaptive(light: NSColor(hex: 0x23891A), dark: NSColor(hex: 0x44D62C)) + /// Text and icons drawn on a `razerGreen` fill. Dark in every theme: white on #44D62C is + /// only about 1.9:1 contrast. + static let onRazerGreen = Color(nsColor: NSColor(hex: 0x0D2B07)) + /// The logo's green: brighter than the brand green so it pops on dark, and the darker + /// text green on light, where a bright green washes out. + static let razerGreenBright = adaptive(light: NSColor(hex: 0x23891A), dark: NSColor(hex: 0x6BFF40)) + /// The Bluetooth chip's colour. System blue is too dark to read on a dark background, + /// and this lighter blue too pale on a light one. + static let bluetoothBlue = adaptive(light: NSColor(hex: 0x0A64D6), dark: NSColor(hex: 0x78B8FF)) // Battery *state* uses Apple system colors (meaning), never the brand green. static let batteryFull = Color(red: 0x34 / 255, green: 0xC7 / 255, blue: 0x59 / 255) static let batteryMid = Color(red: 0xFF / 255, green: 0x9F / 255, blue: 0x0A / 255) @@ -29,6 +43,10 @@ struct PopoverView: View { @ObservedObject var remapper: ButtonRemapper @ObservedObject var updateChecker: UpdateChecker @ObservedObject var launchAtLogin: LaunchAtLogin + /// Settings › Appearance; only the Razer Green glass edge is drawn here. The rest is the + /// popover's appearance and backdrop, which the app delegate sets. + @ObservedObject var appearance: AppearanceSettings + @Environment(\.colorScheme) private var colorScheme /// Opening a window is the owner's job — the popover doesn't know about window controllers. /// Deliberately not defaulted: a no-op default would let a future call site forget the /// handler and ship a gear button that depresses and does nothing, with no compiler error. @@ -88,6 +106,9 @@ struct PopoverView: View { /// Not the colour page: it's a fixed-size picker, centred, and would only gain empty space. private let subPageMinHeight: CGFloat = 560 @State private var mainHeight: CGFloat = 0 + /// The corner radius NSPopover gives its body, so the green edge sits on the outline. + /// Measured from a capture on macOS 26; the content view is exactly the body. + static let popoverCornerRadius: CGFloat = 10 var body: some View { ZStack { @@ -107,6 +128,15 @@ struct PopoverView: View { // main page's height instead of resizing once the transition finishes. .onPreferenceChange(HeightKey.self) { if $0 > 0 { mainHeight = $0 } } .animation(.easeInOut(duration: 0.26), value: page) + .overlay { + if appearance.look(systemIsDark: colorScheme == .dark).hasGreenEdge { + // Follows the body's rounded outline; the arrow above it has no edge. + RoundedRectangle(cornerRadius: Self.popoverCornerRadius) + .strokeBorder(Color.razerGreen.opacity(0.45), lineWidth: 1) + .ignoresSafeArea() + .allowsHitTesting(false) + } + } // Closing the popover resets navigation: reopening onto a stale sub-page (whose // gating card may have been disabled in the meantime — e.g. Profiles after a // disconnect) is disorienting. @@ -278,7 +308,7 @@ struct PopoverView: View { .font(.system(size: 11, weight: .medium)) } .buttonStyle(.borderless) - .foregroundStyle(Color.razerGreen) + .foregroundStyle(Color.razerGreenText) } else { Button { controller.refreshAll() @@ -356,7 +386,7 @@ struct PopoverView: View { Image(systemName: "arrow.right").font(.system(size: 9, weight: .semibold)) } .font(.system(size: 11, weight: .medium)) - .foregroundStyle(Color.razerGreen) + .foregroundStyle(Color.razerGreenText) } .buttonStyle(.plain) } @@ -414,7 +444,7 @@ struct PopoverView: View { private struct ConnectionChip { let symbol: String let label: String - var tint: Color = .razerGreen + var tint: Color = .razerGreenText var fillOpacity: Double = 0.15 } @@ -493,7 +523,7 @@ struct PopoverView: View { Button("Manage Profiles…") { page = .profiles } .buttonStyle(.plain) .font(.system(size: 11, weight: .medium)) - .foregroundStyle(Color.razerGreen) + .foregroundStyle(Color.razerGreenText) } } } @@ -512,7 +542,7 @@ struct PopoverView: View { } .buttonStyle(.plain) .font(.system(size: 10.5, weight: active ? .semibold : .regular)) - .foregroundStyle(active ? .white : .secondary) + .foregroundStyle(active ? Color.onRazerGreen : Color.secondary) .frame(height: 24) .background(active ? Color.razerGreen : Color.primary.opacity(0.08), in: Capsule()) .hoverHighlight(Capsule()) @@ -537,7 +567,7 @@ struct PopoverView: View { Image(systemName: "plus").font(.system(size: 10.5, weight: .semibold)) } .buttonStyle(.plain) - .foregroundStyle(Color.razerGreen) + .foregroundStyle(Color.razerGreenText) .frame(width: 24, height: 24) .background(Color.razerGreen.opacity(0.12), in: Circle()) .overlay(Circle().stroke(Color.razerGreen.opacity(0.55), lineWidth: 1)) @@ -579,7 +609,7 @@ struct PopoverView: View { VStack(alignment: .leading, spacing: 8) { HStack(alignment: .top, spacing: 8) { Image(systemName: "arrow.down.circle.fill") - .foregroundStyle(Color.razerGreen) + .foregroundStyle(Color.razerGreenText) .font(.system(size: 14, weight: .semibold)) VStack(alignment: .leading, spacing: 1) { Text("Update available").font(.system(size: 12, weight: .semibold)) @@ -616,7 +646,7 @@ struct PopoverView: View { Spacer(minLength: 0) Image(systemName: "chevron.right").font(.system(size: 9, weight: .semibold)) } - .foregroundStyle(Color.razerGreen) + .foregroundStyle(Color.razerGreenText) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -636,7 +666,7 @@ struct PopoverView: View { } .buttonStyle(.plain) .font(.system(size: 11)) - .foregroundStyle(Color.razerGreen) + .foregroundStyle(Color.razerGreenText) } } .padding(12) @@ -701,7 +731,7 @@ struct PopoverView: View { VStack(alignment: .leading, spacing: 8) { HStack(alignment: .top, spacing: 8) { Image(systemName: "checkmark.circle.fill") - .foregroundStyle(Color.razerGreen) + .foregroundStyle(Color.razerGreenText) .font(.system(size: 14, weight: .semibold)) VStack(alignment: .leading, spacing: 1) { Text("Updated to \(appVersion)").font(.system(size: 12, weight: .semibold)) @@ -728,7 +758,7 @@ struct PopoverView: View { Spacer(minLength: 0) Image(systemName: "chevron.right").font(.system(size: 9, weight: .semibold)) } - .foregroundStyle(Color.razerGreen) + .foregroundStyle(Color.razerGreenText) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -826,7 +856,7 @@ struct PopoverView: View { .font(.system(size: 11, weight: .medium)) } .buttonStyle(.borderless) - .foregroundStyle(Color.razerGreen) + .foregroundStyle(Color.razerGreenText) } } } @@ -992,7 +1022,7 @@ struct PopoverView: View { } .buttonStyle(.plain) .font(.system(size: 10.5, weight: active ? .semibold : .regular).monospacedDigit()) - .foregroundStyle(active ? .white : .secondary) + .foregroundStyle(active ? Color.onRazerGreen : Color.secondary) .frame(maxWidth: .infinity) .frame(height: 24) .background(active ? Color.razerGreen : Color.primary.opacity(0.08), @@ -1057,7 +1087,7 @@ struct PopoverView: View { } .buttonStyle(.plain) .font(.system(size: 9.5, weight: Int(sleepTimeoutValue) == value ? .semibold : .regular).monospacedDigit()) - .foregroundStyle(Int(sleepTimeoutValue) == value ? .white : .secondary) + .foregroundStyle(Int(sleepTimeoutValue) == value ? Color.onRazerGreen : Color.secondary) .frame(maxWidth: .infinity).frame(height: 23) .background(Int(sleepTimeoutValue) == value ? Color.razerGreen : Color.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 6)) .hoverHighlight(RoundedRectangle(cornerRadius: 6)) @@ -1103,7 +1133,7 @@ struct PopoverView: View { } .buttonStyle(.plain) .font(.system(size: 10.5, weight: active ? .semibold : .regular).monospacedDigit()) - .foregroundStyle(active ? .white : Color.razerGreen) + .foregroundStyle(active ? Color.onRazerGreen : Color.razerGreenText) .frame(maxWidth: .infinity) .frame(height: 24) .background(active ? Color.razerGreen : Color.razerGreen.opacity(0.12), @@ -1353,7 +1383,7 @@ struct PopoverView: View { } case .buttons: Button { page = .buttons } label: { - groupRowLabel(title: "Buttons", symbol: "keyboard", tint: .razerGreen, + groupRowLabel(title: "Buttons", symbol: "keyboard", tint: .razerGreenText, value: buttonsSummary, valueColor: buttonsNeedAccess ? .batteryMid : .secondary, expanded: false) @@ -1463,7 +1493,7 @@ struct PopoverView: View { // Nothing at rest marks it as a link — the footer should stay quiet — so // hover has to carry the whole affordance: colour and underline together, // since colour alone is easy to miss at 11pt in tertiary grey. - .foregroundStyle(versionHovered ? AnyShapeStyle(Color.razerGreen) : AnyShapeStyle(.tertiary)) + .foregroundStyle(versionHovered ? AnyShapeStyle(Color.razerGreenText) : AnyShapeStyle(.tertiary)) .underline(versionHovered) } .buttonStyle(.plain) @@ -1476,7 +1506,7 @@ struct PopoverView: View { .font(.system(size: 12.5)) // Same hover treatment as the version link beside it — a footer this // quiet needs the cue, and two different ones would be noise. - .foregroundStyle(gearHovered ? AnyShapeStyle(Color.razerGreen) : AnyShapeStyle(.secondary)) + .foregroundStyle(gearHovered ? AnyShapeStyle(Color.razerGreenText) : AnyShapeStyle(.secondary)) .contentShape(Rectangle()) } .buttonStyle(.plain) diff --git a/Sources/MacRazer/ProfilesView.swift b/Sources/MacRazer/ProfilesView.swift index 2d737f9..421417e 100644 --- a/Sources/MacRazer/ProfilesView.swift +++ b/Sources/MacRazer/ProfilesView.swift @@ -82,7 +82,7 @@ struct ProfilesView: View { Circle().fill(active ? Color.razerGreen : Color.primary.opacity(0.10)) if active { Image(systemName: "checkmark").font(.system(size: 10, weight: .bold)) - .foregroundStyle(.white) + .foregroundStyle(Color.onRazerGreen) } } .frame(width: 20, height: 20) diff --git a/Sources/MacRazer/RemapView.swift b/Sources/MacRazer/RemapView.swift index 2849694..d5b2d1c 100644 --- a/Sources/MacRazer/RemapView.swift +++ b/Sources/MacRazer/RemapView.swift @@ -160,7 +160,7 @@ struct RemapView: View { private var detectionHint: some View { HStack(spacing: 8) { - Image(systemName: "cursorarrow.click.2").foregroundStyle(Color.razerGreen) + Image(systemName: "cursorarrow.click.2").foregroundStyle(Color.razerGreenText) if let b = remapper.lastDetectedButton { Text("Detected: \(ButtonRemapper.label(for: b))") .font(.system(size: 12, weight: .medium)) diff --git a/Sources/MacRazer/RemapWindowController.swift b/Sources/MacRazer/RemapWindowController.swift index 71ed2f5..f53f4d2 100644 --- a/Sources/MacRazer/RemapWindowController.swift +++ b/Sources/MacRazer/RemapWindowController.swift @@ -27,6 +27,8 @@ final class RemapWindowController: AppWindowPresenter { w.title = "Configure Buttons" w.styleMask = [.titled, .closable, .miniaturizable] w.isReleasedWhenClosed = false + // Themed like the popover per Settings › Appearance, kept up to date when it changes. + AppearanceSettings.shared.track(w) w.center() window = w } diff --git a/Sources/MacRazer/SettingsView.swift b/Sources/MacRazer/SettingsView.swift index 4fba290..369918a 100644 --- a/Sources/MacRazer/SettingsView.swift +++ b/Sources/MacRazer/SettingsView.swift @@ -23,6 +23,7 @@ struct SettingsView: View { var onAutoInstallChanged: (() -> Void)? /// The crash window's "Don't Ask Again" writes the same key, so this is how to undo it. @AppStorage(CrashLogScanner.offerKey) private var offerCrashReports = true + @ObservedObject var appearance: AppearanceSettings = .shared var body: some View { VStack(alignment: .leading, spacing: 18) { @@ -36,6 +37,10 @@ struct SettingsView: View { // Hidden while there's nowhere to send a report; see `CrashReporting`. if CrashReporting.isAvailable { crashReportsRow } } + titledSection("Appearance") { + themeRow + glassRow + } titledSection("Updates") { autoInstallRow versionRow @@ -88,7 +93,7 @@ struct SettingsView: View { } else if launchAtLogin.needsApproval { Button("Approve MacRazer in Login Items…") { launchAtLogin.openLoginItemsSettings() } .buttonStyle(.plain).font(.system(size: 11)) - .foregroundStyle(Color.razerGreen) + .foregroundStyle(Color.razerGreenText) } } } @@ -111,6 +116,32 @@ struct SettingsView: View { ) } + private var themeRow: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Theme").font(.system(size: 12.5, weight: .medium)) + Spacer(minLength: 12) + Picker("Theme", selection: $appearance.theme) { + ForEach(PopoverTheme.allCases) { Text($0.title).tag($0) } + } + .pickerStyle(.segmented) + .labelsHidden() + .controlSize(.small) + .fixedSize() + } + sectionNote("System follows macOS. Razer Green is always dark.") + } + } + + private var glassRow: some View { + settingRow( + title: "Glass effect", + detail: "The popover and windows let the desktop show through. Turn it off for a solid " + + "background, which is easier to read over busy windows.", + isOn: $appearance.glass + ) + } + private var crashReportsRow: some View { settingRow( title: "Offer to send crash reports", diff --git a/Sources/MacRazer/SettingsWindowController.swift b/Sources/MacRazer/SettingsWindowController.swift index 66c7488..11f4128 100644 --- a/Sources/MacRazer/SettingsWindowController.swift +++ b/Sources/MacRazer/SettingsWindowController.swift @@ -41,8 +41,8 @@ final class SettingsWindowController: AppWindowPresenter { w.title = "MacRazer Settings" w.styleMask = [.titled, .closable] w.isReleasedWhenClosed = false - // Match the popover's forced-dark look so the green accent reads correctly. - w.appearance = NSAppearance(named: .darkAqua) + // Themed like the popover per Settings › Appearance, kept up to date when it changes. + AppearanceSettings.shared.track(w) w.center() window = w } diff --git a/Sources/MacRazer/UpdatedView.swift b/Sources/MacRazer/UpdatedView.swift index 8c1f7b0..1ef60c7 100644 --- a/Sources/MacRazer/UpdatedView.swift +++ b/Sources/MacRazer/UpdatedView.swift @@ -71,7 +71,7 @@ struct UpdatedView: View { } else { Link("Read the release notes on GitHub", destination: ProjectLinks.latestRelease) .font(.system(size: 12, weight: .medium)) - .foregroundStyle(Color.razerGreen) + .foregroundStyle(Color.razerGreenText) } } } diff --git a/Sources/MacRazer/UpdatedWindowController.swift b/Sources/MacRazer/UpdatedWindowController.swift index 88852be..a2a10c5 100644 --- a/Sources/MacRazer/UpdatedWindowController.swift +++ b/Sources/MacRazer/UpdatedWindowController.swift @@ -38,8 +38,8 @@ final class UpdatedWindowController: NSObject, AppWindowPresenter, NSWindowDeleg w.title = "MacRazer Updated" w.styleMask = [.titled, .closable] w.isReleasedWhenClosed = false - // Match the other windows: the green accent needs a dark ground. - w.appearance = NSAppearance(named: .darkAqua) + // Themed like the popover per Settings › Appearance, kept up to date when it changes. + AppearanceSettings.shared.track(w) w.delegate = self w.center() window = w diff --git a/Sources/MacRazer/WhatsNewPage.swift b/Sources/MacRazer/WhatsNewPage.swift index 5e09552..a8265c6 100644 --- a/Sources/MacRazer/WhatsNewPage.swift +++ b/Sources/MacRazer/WhatsNewPage.swift @@ -77,14 +77,14 @@ struct ReleaseNotesList: View { if releases.count > 1 { Text(release.version) .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(Color.razerGreen) + .foregroundStyle(Color.razerGreenText) .padding(.top, release.id == releases.first?.id ? 0 : 6) } notesBlock(release.notes) } Link("Full release notes", destination: ProjectLinks.latestRelease) .font(.system(size: 11, weight: .medium)) - .foregroundStyle(Color.razerGreen) + .foregroundStyle(Color.razerGreenText) } .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/Sources/MacRazer/main.swift b/Sources/MacRazer/main.swift index b9adf5c..4894619 100644 --- a/Sources/MacRazer/main.swift +++ b/Sources/MacRazer/main.swift @@ -72,11 +72,10 @@ func openDevice() -> HIDDevice? { /// Shared by the render-* commands: draw a SwiftUI view at 2x on the app's dark backdrop /// and write it to a PNG. @MainActor func writeViewPNG(_ view: V, to path: String) { - // Force dark, because every surface these previews stand in for does: the popover sets - // `.darkAqua`, and so do the Settings, Permissions and About windows. Without it - // `.secondary`/`.tertiary` resolve for light mode and render as near-black text on the - // dark backdrop below — which made every preview look like it had a contrast bug it - // doesn't have. + // Rendered in the Dark theme, to match the dark backdrop below. The app itself follows + // Settings › Appearance; without this `.secondary`/`.tertiary` resolve for light mode and + // render as near-black text on that backdrop, which made every preview look like it had + // a contrast bug it doesn't have. let renderer = ImageRenderer( content: view.environment(\.colorScheme, .dark).padding(1).background(Color(white: 0.13))) renderer.scale = 2 @@ -229,7 +228,8 @@ case "render-ui": : args.contains("profiles") ? AnyView(ProfilesView(controller: controller, remapper: ButtonRemapper(), onBack: {})) : AnyView(PopoverView(controller: controller, remapper: ButtonRemapper(), updateChecker: updateChecker, - launchAtLogin: launchAtLogin, onOpenSettings: {}, + launchAtLogin: launchAtLogin, appearance: AppearanceSettings.shared, + onOpenSettings: {}, onOpenDeviceTest: {})) // no windows in a render writeViewPNG(rootView, to: path) diff --git a/Tests/MacRazerTests/PopoverThemeTests.swift b/Tests/MacRazerTests/PopoverThemeTests.swift new file mode 100644 index 0000000..0eac703 --- /dev/null +++ b/Tests/MacRazerTests/PopoverThemeTests.swift @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Part of MacRazer, a control app for Razer mice on macOS. See LICENSE and NOTICE.md. + +import XCTest +import AppKit +@testable import MacRazer + +final class PopoverThemeTests: XCTestCase { + /// Every theme × glass × system mode, against the table the mockups were approved from. + func testEveryCombinationResolvesToTheApprovedLook() { + let expected: [(PopoverTheme, Bool, Bool, PopoverLook)] = [ + (.system, true, false, .lightGlass), + (.system, true, true, .darkGlass), + (.system, false, false, .solidLight), + (.system, false, true, .solidDark), + (.dark, true, false, .darkGlass), + (.dark, true, true, .darkGlass), + (.dark, false, false, .solidDark), + (.dark, false, true, .solidDark), + (.light, true, false, .lightGlass), + (.light, true, true, .lightGlass), + (.light, false, false, .solidLight), + (.light, false, true, .solidLight), + // Razer Green stays dark on a light Mac. + (.razerGreen, true, false, .greenGlass), + (.razerGreen, true, true, .greenGlass), + (.razerGreen, false, false, .solidGreen), + (.razerGreen, false, true, .solidGreen), + ] + XCTAssertEqual(expected.count, PopoverTheme.allCases.count * 2 * 2) + for (theme, glass, systemIsDark, look) in expected { + XCTAssertEqual(PopoverLook.resolve(theme: theme, glass: glass, systemIsDark: systemIsDark), look, + "\(theme), glass \(glass), system dark \(systemIsDark)") + } + } + + func testLookProperties() { + for look in PopoverLook.allCases { + XCTAssertEqual(look.appearanceName, look.isDark ? .darkAqua : .aqua) + // Plain glass keeps the native material; everything else paints over it. + let plainGlass = look == .lightGlass || look == .darkGlass + XCTAssertEqual(look.backdropColor == nil, plainGlass, "\(look)") + // A solid look must actually be solid, or the material shows through. + if !look.isGlass { XCTAssertEqual(look.backdropColor?.alphaComponent, 1, "\(look)") } + } + XCTAssertEqual(PopoverLook.allCases.filter(\.hasGreenEdge), [.greenGlass]) + XCTAssertFalse(PopoverLook.lightGlass.isDark) + XCTAssertFalse(PopoverLook.solidLight.isDark) + XCTAssertTrue(PopoverLook.solidGreen.isDark) + } + + @MainActor + func testSettingsDefaultAndPersist() throws { + let suite = "PopoverThemeTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + + let settings = AppearanceSettings(defaults: defaults) + XCTAssertEqual(settings.theme, .system) + XCTAssertTrue(settings.glass) + + settings.theme = .razerGreen + settings.glass = false + let reloaded = AppearanceSettings(defaults: defaults) + XCTAssertEqual(reloaded.theme, .razerGreen) + XCTAssertFalse(reloaded.glass) + XCTAssertEqual(reloaded.look(systemIsDark: false), .solidGreen) + } + + @MainActor + func testUnknownStoredThemeFallsBackToSystem() throws { + let suite = "PopoverThemeTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + + defaults.set("neon", forKey: AppearanceSettings.themeKey) + XCTAssertEqual(AppearanceSettings(defaults: defaults).theme, .system) + } + + @MainActor + func testTrackedWindowsGetTheFullLook() throws { + let suite = "PopoverThemeTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + + let settings = AppearanceSettings(defaults: defaults) + settings.applyToWindows(.solidLight) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 200, height: 100), + styleMask: [.titled], backing: .buffered, defer: true) + window.isReleasedWhenClosed = false + window.contentView = NSView() + // A window opened later takes the look the others already have. + settings.track(window) + XCTAssertEqual(window.appearance?.name, .aqua) + XCTAssertTrue(window.isOpaque) + XCTAssertEqual(window.backgroundColor, PopoverLook.solidLight.backdropColor) + + settings.applyToWindows(.greenGlass) + XCTAssertEqual(window.appearance?.name, .darkAqua) + XCTAssertFalse(window.isOpaque) + XCTAssertEqual(window.backgroundColor, .clear) + let frameView = try XCTUnwrap(window.contentView?.superview) + let glass = try XCTUnwrap(frameView.subviews.compactMap { $0 as? NSVisualEffectView }.first) + XCTAssertFalse(glass.isHidden) + let tint = try XCTUnwrap(frameView.subviews.compactMap { $0 as? PopoverBackdropView }.first) + XCTAssertEqual(tint.fill, PopoverLook.greenGlass.backdropColor) + + // Applying again reuses the same views rather than stacking new ones. + settings.applyToWindows(.solidDark) + XCTAssertEqual(frameView.subviews.compactMap { $0 as? NSVisualEffectView }.count, 1) + XCTAssertTrue(glass.isHidden) + XCTAssertNil(tint.fill) + XCTAssertEqual(window.backgroundColor, PopoverLook.solidDark.backdropColor) + } +} diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index 5a91995..dd65702 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -230,7 +230,7 @@ A fixed **320*620** navigable container with three pages and a push/pop slide an page is a `ScrollView` (so long content scrolls without resizing the popover): - **Main page**, Control-Center-style frosted cards, each section its own tile: - - **Header card**: Razer logo (bright green) in a tinted tile + detected device name + + - **Header card**: Razer logo (green) in a tinted tile + detected device name + connection state + status dot. Shows "No mouse connected" when none. - **Battery card**: a custom proportional **battery gauge** (fill tracks the exact %, colored green >=40% / orange <40% / red <15%), the big % number, a state-colored level @@ -263,6 +263,26 @@ page is a `ScrollView` (so long content scrolls without resizing the popover): progress, where Esc cancels that). - Swatches, chips and the icon buttons carry VoiceOver labels and tooltips; chips and rows get a hover highlight. +- **Appearance**, `PopoverTheme.swift`: Settings › Appearance has a **Theme** (System, Dark, + Light, Razer Green) and a **Glass effect** switch, stored as `appearance.theme` and + `appearance.glass` in UserDefaults. `PopoverLook.resolve` turns theme, glass and the system's + light or dark mode into one of six looks (light, dark or green glass; solid light #F5F5F7, + dark #1C1C1E or green #0F1A11). Razer Green is always dark. + - `AppDelegate` re-applies the look when the setting changes or macOS switches mode, including + while the popover is open. The popover's appearance is always set explicitly: a nil one would + follow the menu bar, which macOS tints from the wallpaper. + - Solid colours and the green tint are painted by `PopoverBackdropView`, which sits in + NSPopover's frame view just under the content. That view clips to the popover's outline, so + the fill covers the arrow too. A SwiftUI background would leave the arrow glass. The green + edge is a SwiftUI overlay on the body (radius 10, measured on macOS 26). + - Windows get the same look (`AppearanceSettings.track`, `WindowBackdrop`): glass is an + `NSVisualEffectView` with the popover's material, plus the same tint view, both in the + window's frame view so the title bar matches; solid looks set the window's background colour. + The title bar is transparent in every look. Only the popover gets the green edge. + - Colours: `razerGreen` is the fill (the same in both modes); text and icons use + `razerGreenText` (#23891A on light). Anything on a green fill uses `onRazerGreen`, a dark green, + because white on #44D62C is only about 1.9:1. `razerGreenBright` (logo) and `bluetoothBlue` + also adapt. - **Colour page**, `ColorPickerPage.swift`: an inline **hue/saturation colour wheel** + brightness slider + live preview, applied to the mouse live (throttled). Replaces the old system `NSColorPanel`. @@ -366,6 +386,7 @@ that path draws the content but needs an explicit size, since there is no window | `RazerDevices.swift` | PID to {name, supported, hasBattery} registry. | | `BatteryHistory.swift` | Sample log + learned discharge rate + time estimate. | | `PopoverView.swift` | Main popover UI + page navigation. | +| `PopoverTheme.swift` | Settings › Appearance: theme, glass, the resolved look, the popover and window backdrops. | | `ColorPickerPage.swift` | Inline hue/sat colour wheel page + shared `BackButton`. | | `ButtonRemapper.swift` | CGEvent tap, action model, presets, persistence, Accessibility. | | `RemapView.swift` | Button-config UI (inline + window), key recorder. |