From a181780641882f442e3e96d39fa0f5bc4d88c541 Mon Sep 17 00:00:00 2001 From: sorcrr Date: Wed, 14 Oct 2026 23:45:49 +0300 Subject: [PATCH 1/4] Add an Appearance setting: follow the system, or pick a theme and glass The popover and every window were forced dark. Settings > Appearance now has a Theme (System, Dark, Light, Razer Green) and a Glass effect switch. The popover follows the system by default and updates live; windows follow light or dark. Solid backgrounds are painted in the popover's frame view so the arrow matches. Green text and the Bluetooth blue adapt to light mode, and selected chips use dark text on green, since white on #44D62C is only about 1.9:1. --- CHANGELOG.md | 11 ++ Sources/MacRazer/AboutView.swift | 10 +- Sources/MacRazer/AboutWindowController.swift | 4 +- Sources/MacRazer/AppDelegate.swift | 45 ++++- .../CrashReportWindowController.swift | 4 +- Sources/MacRazer/DeviceTestView.swift | 12 +- .../MacRazer/DeviceTestWindowController.swift | 4 +- .../PermissionsWindowController.swift | 4 +- Sources/MacRazer/PopoverTheme.swift | 160 ++++++++++++++++++ Sources/MacRazer/PopoverView.swift | 85 +++++++--- Sources/MacRazer/ProfilesView.swift | 2 +- Sources/MacRazer/RemapView.swift | 2 +- Sources/MacRazer/RemapWindowController.swift | 2 + Sources/MacRazer/SettingsView.swift | 33 +++- .../MacRazer/SettingsWindowController.swift | 4 +- Sources/MacRazer/UpdatedView.swift | 2 +- .../MacRazer/UpdatedWindowController.swift | 4 +- Sources/MacRazer/WhatsNewPage.swift | 4 +- Sources/MacRazer/main.swift | 12 +- Tests/MacRazerTests/PopoverThemeTests.swift | 105 ++++++++++++ docs/DOCUMENTATION.md | 21 ++- 21 files changed, 464 insertions(+), 66 deletions(-) create mode 100644 Sources/MacRazer/PopoverTheme.swift create mode 100644 Tests/MacRazerTests/PopoverThemeTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 55e9aa7..9f62731 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. 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,11 @@ expect rough edges until 1.0. it reads the answers, using a fake mouse instead of hardware. ### Added +- **Settings › Appearance.** Choose the popover's theme: System (the default), Dark, Light or + Razer Green, a dark look with a faint green tint and 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..1f35179 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) + // Light or dark 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..12e3398 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,29 @@ 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.backdropRGBA.map { + NSColor(srgbRed: $0.red, green: $0.green, blue: $0.blue, alpha: $0.alpha) + } + appearance.applyToWindows(theme: theme) + } + + /// 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/CrashReportWindowController.swift b/Sources/MacRazer/CrashReportWindowController.swift index 0e88ed2..6749ffd 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) + // Light or dark 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..b14ccf5 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: ", ")) } @@ -366,7 +366,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 +403,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..4cf580c 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) + // Light or dark 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..25b7434 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) + // Light or dark 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..8bfffcc --- /dev/null +++ b/Sources/MacRazer/PopoverTheme.swift @@ -0,0 +1,160 @@ +// 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" + } + } + + /// The appearance the app's windows use. Windows only follow light or dark: the glass and + /// the green tint are the popover's. Nil follows macOS. + var windowAppearanceName: NSAppearance.Name? { + switch self { + case .system: return nil + case .dark, .razerGreen: return .darkAqua + case .light: return .aqua + } + } +} + +/// What the popover actually looks 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 backdropRGBA: (red: Double, green: Double, blue: Double, alpha: Double)? { + switch self { + case .lightGlass, .darkGlass: return nil + case .greenGlass: return (0x10 / 255, 0x3A / 255, 0x14 / 255, 0.35) + case .solidLight: return (0xF5 / 255, 0xF5 / 255, 0xF7 / 255, 1) + case .solidDark: return (0x1C / 255, 0x1C / 255, 0x1E / 255, 1) + case .solidGreen: return (0x0F / 255, 0x1A / 255, 0x11 / 255, 1) + } + } + + 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() + + /// Gives a window the theme's light or dark appearance and keeps it updated when the + /// setting changes. + func track(_ window: NSWindow) { + windows.add(window) + Self.apply(theme, to: window) + } + + /// Re-applies a theme to every tracked window. Takes the theme rather than reading + /// `theme`, because the owner calls this from a `$theme` subscriber, which runs before the + /// property itself has changed. + func applyToWindows(theme: PopoverTheme) { + for window in windows.allObjects { Self.apply(theme, to: window) } + } + + private static func apply(_ theme: PopoverTheme, to window: NSWindow) { + window.appearance = theme.windowAppearanceName.flatMap(NSAppearance.init(named:)) + } +} + +extension NSAppearance { + var isDark: Bool { bestMatch(from: [.aqua, .darkAqua]) == .darkAqua } +} + +/// Paints the solid colour (or the green tint) behind the popover's content. +/// +/// 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..98cb782 100644 --- a/Sources/MacRazer/PopoverView.swift +++ b/Sources/MacRazer/PopoverView.swift @@ -10,14 +10,33 @@ 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 }) +} + +private func srgbHex(_ hex: UInt32) -> NSColor { + NSColor(srgbRed: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255, + blue: CGFloat(hex & 0xFF) / 255, alpha: 1) +} + 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: srgbHex(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: srgbHex(0x23891A), dark: srgbHex(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: srgbHex(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: srgbHex(0x23891A), dark: srgbHex(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: srgbHex(0x0A64D6), dark: srgbHex(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 +48,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 +111,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 +133,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 +313,7 @@ struct PopoverView: View { .font(.system(size: 11, weight: .medium)) } .buttonStyle(.borderless) - .foregroundStyle(Color.razerGreen) + .foregroundStyle(Color.razerGreenText) } else { Button { controller.refreshAll() @@ -356,7 +391,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 +449,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 +528,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 +547,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 +572,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 +614,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 +651,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 +671,7 @@ struct PopoverView: View { } .buttonStyle(.plain) .font(.system(size: 11)) - .foregroundStyle(Color.razerGreen) + .foregroundStyle(Color.razerGreenText) } } .padding(12) @@ -701,7 +736,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 +763,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 +861,7 @@ struct PopoverView: View { .font(.system(size: 11, weight: .medium)) } .buttonStyle(.borderless) - .foregroundStyle(Color.razerGreen) + .foregroundStyle(Color.razerGreenText) } } } @@ -992,7 +1027,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 +1092,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 +1138,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 +1388,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 +1498,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 +1511,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..25e9a3a 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 + // Light or dark 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..e418da6 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 lets 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..f99c87b 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) + // Light or dark 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..2103225 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) + // Light or dark 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..25620ab --- /dev/null +++ b/Tests/MacRazerTests/PopoverThemeTests.swift @@ -0,0 +1,105 @@ +// 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.backdropRGBA == nil, plainGlass, "\(look)") + // A solid look must actually be solid, or the material shows through. + if !look.isGlass { XCTAssertEqual(look.backdropRGBA?.alpha, 1, "\(look)") } + } + XCTAssertEqual(PopoverLook.allCases.filter(\.hasGreenEdge), [.greenGlass]) + XCTAssertFalse(PopoverLook.lightGlass.isDark) + XCTAssertFalse(PopoverLook.solidLight.isDark) + XCTAssertTrue(PopoverLook.solidGreen.isDark) + } + + func testWindowsFollowOnlyLightOrDark() { + XCTAssertNil(PopoverTheme.system.windowAppearanceName) + XCTAssertEqual(PopoverTheme.dark.windowAppearanceName, .darkAqua) + XCTAssertEqual(PopoverTheme.light.windowAppearanceName, .aqua) + XCTAssertEqual(PopoverTheme.razerGreen.windowAppearanceName, .darkAqua) + } + + @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 testTrackedWindowsFollowTheTheme() throws { + let suite = "PopoverThemeTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + + let settings = AppearanceSettings(defaults: defaults) + settings.theme = .light + let window = NSWindow(contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: true) + window.isReleasedWhenClosed = false + settings.track(window) + XCTAssertEqual(window.appearance?.name, .aqua) + + settings.applyToWindows(theme: .razerGreen) + XCTAssertEqual(window.appearance?.name, .darkAqua) + settings.applyToWindows(theme: .system) + XCTAssertNil(window.appearance) + } +} diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index 5a91995..a504fed 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,24 @@ 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 only follow light or dark (`AppearanceSettings.track`); the glass and the tint are + the popover's. + - 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 +384,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 backdrop. | | `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. | From c7661f1c1e0742bc30ac9fc33dcbc2212165fe75 Mon Sep 17 00:00:00 2001 From: sorcrr Date: Wed, 14 Oct 2026 23:48:39 +0300 Subject: [PATCH 2/4] Fix review findings on the Appearance setting The colour page's swatch and wheel outlines were white, which vanishes on a light popover; they now use the primary colour. The backdrop colour is returned as an NSColor from PopoverLook instead of an RGBA tuple. --- Sources/MacRazer/AppDelegate.swift | 4 +--- Sources/MacRazer/ColorPickerPage.swift | 4 ++-- Sources/MacRazer/PopoverTheme.swift | 15 ++++++++++----- Tests/MacRazerTests/PopoverThemeTests.swift | 4 ++-- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/Sources/MacRazer/AppDelegate.swift b/Sources/MacRazer/AppDelegate.swift index 12e3398..313bc41 100644 --- a/Sources/MacRazer/AppDelegate.swift +++ b/Sources/MacRazer/AppDelegate.swift @@ -527,9 +527,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, UNU 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.backdropRGBA.map { - NSColor(srgbRed: $0.red, green: $0.green, blue: $0.blue, alpha: $0.alpha) - } + popoverBackdrop.fill = look.backdropColor appearance.applyToWindows(theme: theme) } 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/PopoverTheme.swift b/Sources/MacRazer/PopoverTheme.swift index 8bfffcc..af9a38a 100644 --- a/Sources/MacRazer/PopoverTheme.swift +++ b/Sources/MacRazer/PopoverTheme.swift @@ -66,16 +66,21 @@ enum PopoverLook: Equatable, CaseIterable { /// 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 backdropRGBA: (red: Double, green: Double, blue: Double, alpha: Double)? { + var backdropColor: NSColor? { switch self { case .lightGlass, .darkGlass: return nil - case .greenGlass: return (0x10 / 255, 0x3A / 255, 0x14 / 255, 0.35) - case .solidLight: return (0xF5 / 255, 0xF5 / 255, 0xF7 / 255, 1) - case .solidDark: return (0x1C / 255, 0x1C / 255, 0x1E / 255, 1) - case .solidGreen: return (0x0F / 255, 0x1A / 255, 0x11 / 255, 1) + case .greenGlass: return Self.srgb(0x103A14, alpha: 0.35) + case .solidLight: return Self.srgb(0xF5F5F7) + case .solidDark: return Self.srgb(0x1C1C1E) + case .solidGreen: return Self.srgb(0x0F1A11) } } + private static func srgb(_ hex: UInt32, alpha: CGFloat = 1) -> NSColor { + NSColor(srgbRed: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255, + blue: CGFloat(hex & 0xFF) / 255, alpha: alpha) + } + var appearanceName: NSAppearance.Name { isDark ? .darkAqua : .aqua } } diff --git a/Tests/MacRazerTests/PopoverThemeTests.swift b/Tests/MacRazerTests/PopoverThemeTests.swift index 25620ab..9d41f3d 100644 --- a/Tests/MacRazerTests/PopoverThemeTests.swift +++ b/Tests/MacRazerTests/PopoverThemeTests.swift @@ -39,9 +39,9 @@ final class PopoverThemeTests: XCTestCase { 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.backdropRGBA == nil, plainGlass, "\(look)") + XCTAssertEqual(look.backdropColor == nil, plainGlass, "\(look)") // A solid look must actually be solid, or the material shows through. - if !look.isGlass { XCTAssertEqual(look.backdropRGBA?.alpha, 1, "\(look)") } + if !look.isGlass { XCTAssertEqual(look.backdropColor?.alphaComponent, 1, "\(look)") } } XCTAssertEqual(PopoverLook.allCases.filter(\.hasGreenEdge), [.greenGlass]) XCTAssertFalse(PopoverLook.lightGlass.isDark) From c48053beb7e624692644207f7f80f06371da0648 Mon Sep 17 00:00:00 2001 From: sorcrr Date: Thu, 15 Oct 2026 00:14:11 +0300 Subject: [PATCH 3/4] Give the app's windows the full Appearance look Settings, About and the other windows only followed light or dark. They now get the same glass, solid background or Razer Green tint as the popover. The glass and tint sit in each window's frame view so the title bar matches. --- CHANGELOG.md | 7 +- Sources/MacRazer/AboutWindowController.swift | 2 +- Sources/MacRazer/AppDelegate.swift | 2 +- .../CrashReportWindowController.swift | 2 +- .../MacRazer/DeviceTestWindowController.swift | 2 +- .../PermissionsWindowController.swift | 2 +- Sources/MacRazer/PopoverTheme.swift | 90 ++++++++++++++----- Sources/MacRazer/RemapWindowController.swift | 2 +- Sources/MacRazer/SettingsView.swift | 2 +- .../MacRazer/SettingsWindowController.swift | 2 +- .../MacRazer/UpdatedWindowController.swift | 2 +- Tests/MacRazerTests/PopoverThemeTests.swift | 36 +++++--- docs/DOCUMENTATION.md | 8 +- 13 files changed, 108 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f62731..b0dcc65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ expect rough edges until 1.0. ### 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. Selected chips now use dark text on the green + 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. @@ -45,8 +45,9 @@ expect rough edges until 1.0. it reads the answers, using a fake mouse instead of hardware. ### Added -- **Settings › Appearance.** Choose the popover's theme: System (the default), Dark, Light or - Razer Green, a dark look with a faint green tint and a thin green edge. Turn off "Glass effect" +- **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. diff --git a/Sources/MacRazer/AboutWindowController.swift b/Sources/MacRazer/AboutWindowController.swift index 1f35179..33f3d12 100644 --- a/Sources/MacRazer/AboutWindowController.swift +++ b/Sources/MacRazer/AboutWindowController.swift @@ -31,7 +31,7 @@ final class AboutWindowController: AppWindowPresenter { w.title = "About MacRazer" w.styleMask = [.titled, .closable] w.isReleasedWhenClosed = false - // Light or dark per Settings › Appearance, kept up to date when it changes. + // Themed like the popover per Settings › Appearance, kept up to date when it changes. AppearanceSettings.shared.track(w) w.center() hosting = controller diff --git a/Sources/MacRazer/AppDelegate.swift b/Sources/MacRazer/AppDelegate.swift index 313bc41..064cafc 100644 --- a/Sources/MacRazer/AppDelegate.swift +++ b/Sources/MacRazer/AppDelegate.swift @@ -528,7 +528,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, UNU let look = PopoverLook.resolve(theme: theme, glass: glass, systemIsDark: systemIsDark) popover.appearance = NSAppearance(named: look.appearanceName) popoverBackdrop.fill = look.backdropColor - appearance.applyToWindows(theme: theme) + appearance.applyToWindows(look) } /// Puts the backdrop into the popover's frame view, just under the content: above the diff --git a/Sources/MacRazer/CrashReportWindowController.swift b/Sources/MacRazer/CrashReportWindowController.swift index 6749ffd..e5229e9 100644 --- a/Sources/MacRazer/CrashReportWindowController.swift +++ b/Sources/MacRazer/CrashReportWindowController.swift @@ -60,7 +60,7 @@ final class CrashReportWindowController: NSObject, AppWindowPresenter, NSWindowD w.title = "MacRazer Crash Report" w.styleMask = [.titled, .closable] w.isReleasedWhenClosed = false - // Light or dark per Settings › Appearance, kept up to date when it changes. + // Themed like the popover per Settings › Appearance, kept up to date when it changes. AppearanceSettings.shared.track(w) w.delegate = self w.center() diff --git a/Sources/MacRazer/DeviceTestWindowController.swift b/Sources/MacRazer/DeviceTestWindowController.swift index 4cf580c..13b0a1b 100644 --- a/Sources/MacRazer/DeviceTestWindowController.swift +++ b/Sources/MacRazer/DeviceTestWindowController.swift @@ -21,7 +21,7 @@ final class DeviceTestWindowController: NSObject, AppWindowPresenter, NSWindowDe let w = NSWindow(contentViewController: hosting) w.styleMask = [.titled, .closable] w.isReleasedWhenClosed = false - // Light or dark per Settings › Appearance, kept up to date when it changes. + // Themed like the popover per Settings › Appearance, kept up to date when it changes. AppearanceSettings.shared.track(w) w.delegate = self w.center() diff --git a/Sources/MacRazer/PermissionsWindowController.swift b/Sources/MacRazer/PermissionsWindowController.swift index 25b7434..c676b05 100644 --- a/Sources/MacRazer/PermissionsWindowController.swift +++ b/Sources/MacRazer/PermissionsWindowController.swift @@ -29,7 +29,7 @@ final class PermissionsWindowController: AppWindowPresenter { w.title = "MacRazer Setup" w.styleMask = [.titled, .closable] w.isReleasedWhenClosed = false - // Light or dark per Settings › Appearance, kept up to date when it changes. + // 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 index af9a38a..d761a66 100644 --- a/Sources/MacRazer/PopoverTheme.swift +++ b/Sources/MacRazer/PopoverTheme.swift @@ -18,20 +18,10 @@ enum PopoverTheme: String, CaseIterable, Identifiable { case .razerGreen: return "Razer Green" } } - - /// The appearance the app's windows use. Windows only follow light or dark: the glass and - /// the green tint are the popover's. Nil follows macOS. - var windowAppearanceName: NSAppearance.Name? { - switch self { - case .system: return nil - case .dark, .razerGreen: return .darkAqua - case .light: return .aqua - } - } } -/// What the popover actually looks like once the theme, the glass switch and the system's -/// light or dark mode are combined. One case per mockup. +/// 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 @@ -119,23 +109,76 @@ final class AppearanceSettings: ObservableObject { // 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 theme's light or dark appearance and keeps it updated when the - /// setting changes. + /// 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) - Self.apply(theme, to: window) + let look = windowLook ?? look(systemIsDark: NSApplication.shared.effectiveAppearance.isDark) + WindowBackdrop.apply(look, to: window) } - /// Re-applies a theme to every tracked window. Takes the theme rather than reading - /// `theme`, because the owner calls this from a `$theme` subscriber, which runs before the - /// property itself has changed. - func applyToWindows(theme: PopoverTheme) { - for window in windows.allObjects { Self.apply(theme, 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) } } +} - private static func apply(_ theme: PopoverTheme, to window: NSWindow) { - window.appearance = theme.windowAppearanceName.flatMap(NSAppearance.init(named:)) +/// 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) } } @@ -143,7 +186,8 @@ extension NSAppearance { var isDark: Bool { bestMatch(from: [.aqua, .darkAqua]) == .darkAqua } } -/// Paints the solid colour (or the green tint) behind the popover's content. +/// 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 diff --git a/Sources/MacRazer/RemapWindowController.swift b/Sources/MacRazer/RemapWindowController.swift index 25e9a3a..f53f4d2 100644 --- a/Sources/MacRazer/RemapWindowController.swift +++ b/Sources/MacRazer/RemapWindowController.swift @@ -27,7 +27,7 @@ final class RemapWindowController: AppWindowPresenter { w.title = "Configure Buttons" w.styleMask = [.titled, .closable, .miniaturizable] w.isReleasedWhenClosed = false - // Light or dark per Settings › Appearance, kept up to date when it changes. + // 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 e418da6..369918a 100644 --- a/Sources/MacRazer/SettingsView.swift +++ b/Sources/MacRazer/SettingsView.swift @@ -136,7 +136,7 @@ struct SettingsView: View { private var glassRow: some View { settingRow( title: "Glass effect", - detail: "The popover lets the desktop show through. Turn it off for a solid " + 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 ) diff --git a/Sources/MacRazer/SettingsWindowController.swift b/Sources/MacRazer/SettingsWindowController.swift index f99c87b..11f4128 100644 --- a/Sources/MacRazer/SettingsWindowController.swift +++ b/Sources/MacRazer/SettingsWindowController.swift @@ -41,7 +41,7 @@ final class SettingsWindowController: AppWindowPresenter { w.title = "MacRazer Settings" w.styleMask = [.titled, .closable] w.isReleasedWhenClosed = false - // Light or dark per Settings › Appearance, kept up to date when it changes. + // 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/UpdatedWindowController.swift b/Sources/MacRazer/UpdatedWindowController.swift index 2103225..a2a10c5 100644 --- a/Sources/MacRazer/UpdatedWindowController.swift +++ b/Sources/MacRazer/UpdatedWindowController.swift @@ -38,7 +38,7 @@ final class UpdatedWindowController: NSObject, AppWindowPresenter, NSWindowDeleg w.title = "MacRazer Updated" w.styleMask = [.titled, .closable] w.isReleasedWhenClosed = false - // Light or dark per Settings › Appearance, kept up to date when it changes. + // Themed like the popover per Settings › Appearance, kept up to date when it changes. AppearanceSettings.shared.track(w) w.delegate = self w.center() diff --git a/Tests/MacRazerTests/PopoverThemeTests.swift b/Tests/MacRazerTests/PopoverThemeTests.swift index 9d41f3d..0eac703 100644 --- a/Tests/MacRazerTests/PopoverThemeTests.swift +++ b/Tests/MacRazerTests/PopoverThemeTests.swift @@ -49,13 +49,6 @@ final class PopoverThemeTests: XCTestCase { XCTAssertTrue(PopoverLook.solidGreen.isDark) } - func testWindowsFollowOnlyLightOrDark() { - XCTAssertNil(PopoverTheme.system.windowAppearanceName) - XCTAssertEqual(PopoverTheme.dark.windowAppearanceName, .darkAqua) - XCTAssertEqual(PopoverTheme.light.windowAppearanceName, .aqua) - XCTAssertEqual(PopoverTheme.razerGreen.windowAppearanceName, .darkAqua) - } - @MainActor func testSettingsDefaultAndPersist() throws { let suite = "PopoverThemeTests.\(UUID().uuidString)" @@ -85,21 +78,38 @@ final class PopoverThemeTests: XCTestCase { } @MainActor - func testTrackedWindowsFollowTheTheme() throws { + 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.theme = .light - let window = NSWindow(contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: true) + 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(theme: .razerGreen) + settings.applyToWindows(.greenGlass) XCTAssertEqual(window.appearance?.name, .darkAqua) - settings.applyToWindows(theme: .system) - XCTAssertNil(window.appearance) + 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 a504fed..dd65702 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -275,8 +275,10 @@ page is a `ScrollView` (so long content scrolls without resizing the popover): 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 only follow light or dark (`AppearanceSettings.track`); the glass and the tint are - the popover's. + - 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` @@ -384,7 +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 backdrop. | +| `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. | From 0a21a3d33c0ae23034638436cde878e4d6eff3bf Mon Sep 17 00:00:00 2001 From: sorcrr Date: Thu, 15 Oct 2026 00:21:57 +0300 Subject: [PATCH 4/4] Fix review findings: one hex colour helper, see-through comment boxes The popover colours and PopoverLook had separate copies of the same hex to NSColor conversion; both now use NSColor(hex:). The comment boxes in the crash report and device test windows drew an opaque system background over the themed window; they now use a faint fill like the cards. --- Sources/MacRazer/CrashReportView.swift | 4 ++++ Sources/MacRazer/DeviceTestView.swift | 4 ++++ Sources/MacRazer/PopoverTheme.swift | 21 ++++++++++++--------- Sources/MacRazer/PopoverView.swift | 15 +++++---------- 4 files changed, 25 insertions(+), 19 deletions(-) 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/DeviceTestView.swift b/Sources/MacRazer/DeviceTestView.swift index b14ccf5..7ef92e6 100644 --- a/Sources/MacRazer/DeviceTestView.swift +++ b/Sources/MacRazer/DeviceTestView.swift @@ -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) diff --git a/Sources/MacRazer/PopoverTheme.swift b/Sources/MacRazer/PopoverTheme.swift index d761a66..c32a5ad 100644 --- a/Sources/MacRazer/PopoverTheme.swift +++ b/Sources/MacRazer/PopoverTheme.swift @@ -59,18 +59,13 @@ enum PopoverLook: Equatable, CaseIterable { var backdropColor: NSColor? { switch self { case .lightGlass, .darkGlass: return nil - case .greenGlass: return Self.srgb(0x103A14, alpha: 0.35) - case .solidLight: return Self.srgb(0xF5F5F7) - case .solidDark: return Self.srgb(0x1C1C1E) - case .solidGreen: return Self.srgb(0x0F1A11) + 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) } } - private static func srgb(_ hex: UInt32, alpha: CGFloat = 1) -> NSColor { - NSColor(srgbRed: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255, - blue: CGFloat(hex & 0xFF) / 255, alpha: alpha) - } - var appearanceName: NSAppearance.Name { isDark ? .darkAqua : .aqua } } @@ -182,6 +177,14 @@ enum WindowBackdrop { } } +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 } } diff --git a/Sources/MacRazer/PopoverView.swift b/Sources/MacRazer/PopoverView.swift index 98cb782..66114a2 100644 --- a/Sources/MacRazer/PopoverView.swift +++ b/Sources/MacRazer/PopoverView.swift @@ -16,27 +16,22 @@ private func adaptive(light: NSColor, dark: NSColor) -> Color { Color(nsColor: NSColor(name: nil) { $0.isDark ? dark : light }) } -private func srgbHex(_ hex: UInt32) -> NSColor { - NSColor(srgbRed: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255, - blue: CGFloat(hex & 0xFF) / 255, alpha: 1) -} - extension Color { /// 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: srgbHex(0x44D62C)) + 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: srgbHex(0x23891A), dark: srgbHex(0x44D62C)) + 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: srgbHex(0x0D2B07)) + 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: srgbHex(0x23891A), dark: srgbHex(0x6BFF40)) + 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: srgbHex(0x0A64D6), dark: srgbHex(0x78B8FF)) + 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)