From 2ff18970072ebceb985c23797efb148a3aaa1ee5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 18:56:05 +0000 Subject: [PATCH 1/2] Make the popover behave more like a native Mac panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Esc on a sub-page goes back one level instead of closing the popover, except while a key recording or a profile rename owns Esc. What's New now uses the shared BackButton like every other sub-page. - ⌘R refreshes and ⌘, opens Settings from the popover, matching the right-click menu. - Chips and the Configure Buttons row get a hover highlight; colour swatches are real buttons with names; icon-only buttons get VoiceOver labels and tooltips; DPI chips say when right-click edits them. - Sleep timer reads "5 min" / chips "5m" instead of "5m 00s". - With no mouse known at all, one empty-state card with "Look Again" (or the Input Monitoring grant) replaces the column of disabled placeholder controls. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SJ8Eskh7E8TDXYvX2yS9yZ --- CHANGELOG.md | 10 ++ Sources/MacRazer/CardComponents.swift | 20 +++ Sources/MacRazer/ColorPickerPage.swift | 8 ++ Sources/MacRazer/PopoverView.swift | 172 ++++++++++++++++++++----- Sources/MacRazer/ProfilesView.swift | 3 +- Sources/MacRazer/RemapView.swift | 5 +- Sources/MacRazer/WhatsNewPage.swift | 8 +- docs/DOCUMENTATION.md | 8 ++ 8 files changed, 193 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df4b0bb..01a0de2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ expect rough edges until 1.0. ## [Unreleased] ### Changed +- **The popover behaves more like a native Mac panel.** Esc on a sub-page (Colour, Buttons, + Battery Usage, Profiles, What's New) now goes back one level instead of closing the whole + popover, and every sub-page uses the same Back button, What's New included. ⌘R refreshes and + ⌘, opens Settings, matching the right-click menu. Chips and rows highlight on hover, the + colour swatches are real buttons with names, and the icon-only buttons have VoiceOver labels + and tooltips. A DPI chip that can be edited says so in its tooltip, since the editor is only + reachable by right-click. The sleep timer reads "5 min" instead of "5m 00s". + With no mouse plugged in, the popover shows one card explaining how to connect and a + "Look Again" button, instead of a column of greyed-out controls for a mouse that isn't there. + - **Updates are found within hours, not a day.** The app used to ask GitHub once a day. Now it asks every time it starts, and again whenever its last answer is more than three hours old. A Mac that slept longer than that asks as soon as it wakes. If the network isn't back diff --git a/Sources/MacRazer/CardComponents.swift b/Sources/MacRazer/CardComponents.swift index 4c671a9..e59758b 100644 --- a/Sources/MacRazer/CardComponents.swift +++ b/Sources/MacRazer/CardComponents.swift @@ -43,6 +43,26 @@ func sectionNote(_ text: String, color: Color = .secondary) -> some View { .fixedSize(horizontal: false, vertical: true) } +/// A faint wash over a clickable chip or row while the pointer is on it — the feedback +/// Control Center's tiles and menu items give, which plain-styled SwiftUI buttons don't. +/// Skipped while disabled, so a dimmed control doesn't look like it would respond. +private struct HoverHighlight: ViewModifier { + let shape: S + @State private var hovered = false + @Environment(\.isEnabled) private var isEnabled + + func body(content: Content) -> some View { + content + .overlay(shape.fill(Color.primary.opacity(hovered && isEnabled ? 0.08 : 0)).allowsHitTesting(false)) + .onHover { hovered = $0 } + .animation(.easeOut(duration: 0.12), value: hovered) + } +} + +extension View { + func hoverHighlight(_ shape: S) -> some View { modifier(HoverHighlight(shape: shape)) } +} + /// Small secondary-styled section heading used inside cards. func sectionLabel(_ text: String, _ symbol: String) -> some View { Label(text, systemImage: symbol) diff --git a/Sources/MacRazer/ColorPickerPage.swift b/Sources/MacRazer/ColorPickerPage.swift index 8612c30..9ecc215 100644 --- a/Sources/MacRazer/ColorPickerPage.swift +++ b/Sources/MacRazer/ColorPickerPage.swift @@ -4,8 +4,14 @@ import SwiftUI /// A clearly-labelled "‹ Back" button used by the popover sub-pages. +/// +/// Esc triggers it, so a sub-page backs out one level the way a System Settings pane or a +/// Control Center module does, rather than Esc dismissing the whole popover. `escapes` is for +/// pages that own Esc for something narrower while it's in progress (cancelling a key +/// recording or a rename): a navigation there would throw away what the user was doing. struct BackButton: View { var action: () -> Void + var escapes = true var body: some View { Button(action: action) { HStack(spacing: 3) { @@ -17,6 +23,8 @@ struct BackButton: View { .contentShape(Capsule()) } .buttonStyle(.plain) + .keyboardShortcut(escapes ? KeyboardShortcut.cancelAction : nil) + .help("Back (Esc)") } } diff --git a/Sources/MacRazer/PopoverView.swift b/Sources/MacRazer/PopoverView.swift index 199e313..4aedbe4 100644 --- a/Sources/MacRazer/PopoverView.swift +++ b/Sources/MacRazer/PopoverView.swift @@ -66,6 +66,8 @@ struct PopoverView: View { private var displayedStages: [Int] { controller.dpiStages.isEmpty ? defaultStages : controller.dpiStages } + /// Spoken and tooltip names for `swatches`, in the same order. + private let swatchNames = ["Red", "Orange", "Yellow", "Green", "Cyan", "Blue", "Purple", "Pink"] // Distinct hues with a true red (SwiftUI .red is a warm orange-red; .green duplicated razerGreen). private let swatches: [Color] = [ Color(red: 1.0, green: 0.0, blue: 0.0), // true red @@ -171,26 +173,33 @@ struct PopoverView: View { // A Razer mouse on Bluetooth can't be controlled (no control protocol over BT) — // explain it instead of just showing "offline". if controller.bluetoothMouse != nil && !controller.connected { bluetoothNotice } - // Battery stays readable (last-known) but dims when offline; its refresh button - // stays active so you can retry. - if controller.deviceHasBattery { - batteryCard.opacity(controller.connected ? 1 : 0.55) - } - // Live mouse-config sections: dim AND disable while disconnected. - Group { - dpiCard - if controller.supportsPollRate { pollCard } - if controller.deviceHasLighting { lightingCard } // hidden for no-LED mice (e.g. Atheris) - if supportsBasiliskBluetooth { powerManagementCard } - } - .disabled(!controller.connected) - .opacity(controller.connected ? 1 : 0.45) - - configureButton // software remap — works offline, stays enabled - // Profiles bundle the sections above (plus remaps) into presets — placed after - // them so the page reads "here are the controls, here's how to save/recall them". - if controller.supportsProfiles { - profilesCard.disabled(!controller.connected).opacity(controller.connected ? 1 : 0.45) + if noMouse { + // A last reading is still worth showing; with none, the battery card would be + // a "—" and nothing else, so the empty state says what to do instead. + if controller.lastBatterySnapshot != nil { batteryCard.opacity(0.55) } + else { noMouseCard } + } else { + // Battery stays readable (last-known) but dims when offline; its refresh button + // stays active so you can retry. + if controller.deviceHasBattery { + batteryCard.opacity(controller.connected ? 1 : 0.55) + } + // Live mouse-config sections: dim AND disable while disconnected. + Group { + dpiCard + if controller.supportsPollRate { pollCard } + if controller.deviceHasLighting { lightingCard } // hidden for no-LED mice (e.g. Atheris) + if supportsBasiliskBluetooth { powerManagementCard } + } + .disabled(!controller.connected) + .opacity(controller.connected ? 1 : 0.45) + + configureButton // software remap — works offline, stays enabled + // Profiles bundle the sections above (plus remaps) into presets — placed after + // them so the page reads "here are the controls, here's how to save/recall them". + if controller.supportsProfiles { + profilesCard.disabled(!controller.connected).opacity(controller.connected ? 1 : 0.45) + } } footer } @@ -219,6 +228,57 @@ struct PopoverView: View { .onChange(of: controller.sleepTimeout) { _, new in if let new { sleepTimeoutValue = Double(new) } } } + /// No mouse is known at all: never seen this session, or unplugged (which forgets its + /// name). Not a sleeping or out-of-range one, which keeps its name and its dimmed + /// controls. With no model to describe, those controls would be placeholders for a mouse + /// that isn't there — every card disabled, the DPI chips showing defaults — so the page + /// shows one card that says what to do, the way Control Center's Bluetooth module does + /// with nothing paired. + private var noMouse: Bool { + !controller.connected && controller.deviceName == nil && controller.bluetoothMouse == nil + } + + private var noMouseCard: some View { + card { + VStack(spacing: 8) { + Image(systemName: "computermouse") + .font(.system(size: 26, weight: .light)) + .foregroundStyle(.tertiary) + Text(needsPermission ? "MacRazer can't see your mouse yet" : "No Razer mouse found") + .font(.system(size: 12.5, weight: .semibold)) + Text(needsPermission + ? "It needs Input Monitoring permission to talk to the mouse." + : "Plug in the wireless dongle or a USB cable, or move the mouse to wake it.") + .font(.system(size: 11)).foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + if needsPermission { + Button { + AppDelegate.openInputMonitoringSettings() + } label: { + Label("Grant Input Monitoring…", systemImage: "lock.shield") + .font(.system(size: 11, weight: .medium)) + } + .buttonStyle(.borderless) + .foregroundStyle(Color.razerGreen) + } else { + Button { + controller.refreshAll() + } label: { + Text(controller.isRefreshing ? "Looking…" : "Look Again") + .font(.system(size: 11, weight: .medium)) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(controller.isRefreshing) + .keyboardShortcut("r") + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + } + } + private var supportsBasiliskBluetooth: Bool { controller.deviceIsBluetooth && controller.deviceID.map(RazerDevices.supportsStageEditing(pid:)) == true @@ -288,6 +348,7 @@ struct PopoverView: View { Circle() .fill(controller.connected ? Color.green : Color.secondary) .frame(width: 8, height: 8) + .accessibilityLabel(controller.connected ? "Connected" : "Not connected") } } } @@ -440,7 +501,9 @@ struct PopoverView: View { .padding(.horizontal, 10) .frame(height: 24) .background(active ? Color.razerGreen : Color.primary.opacity(0.08), in: Capsule()) + .hoverHighlight(Capsule()) .lineLimit(1) + .accessibilityAddTraits(active ? .isSelected : []) } /// Default name that never collides with an existing profile (count+1 repeats after @@ -517,9 +580,13 @@ struct PopoverView: View { updateChecker.dismiss(version) } label: { Image(systemName: "xmark").font(.system(size: 9, weight: .bold)) + .frame(width: 16, height: 16) + .contentShape(Rectangle()) } .buttonStyle(.plain) .foregroundStyle(.tertiary) + .help("Skip this version") + .accessibilityLabel("Skip version \(version)") } } if let error = updateChecker.downloadError { @@ -632,9 +699,13 @@ struct PopoverView: View { updateChecker.dismissAnnouncement() } label: { Image(systemName: "xmark").font(.system(size: 9, weight: .bold)) + .frame(width: 16, height: 16) + .contentShape(Rectangle()) } .buttonStyle(.plain) .foregroundStyle(.tertiary) + .help("Dismiss") + .accessibilityLabel("Dismiss") } if !updateChecker.installedNotes.isEmpty { Button { page = .whatsNew } label: { @@ -820,6 +891,7 @@ struct PopoverView: View { .buttonStyle(.borderless) .foregroundStyle(.secondary) .help("View battery usage history") + .accessibilityLabel("Battery usage history") } private var refreshButton: some View { @@ -837,7 +909,10 @@ struct PopoverView: View { } .buttonStyle(.borderless) .foregroundStyle(.secondary) - .help(controller.supportsPollRate ? "Refresh battery, DPI and polling rate" : "Refresh battery and DPI") + .help(controller.supportsPollRate ? "Refresh battery, DPI and polling rate (⌘R)" : "Refresh battery and DPI (⌘R)") + .accessibilityLabel("Refresh") + // The same shortcut the right-click menu's "Refresh Now" shows. + .keyboardShortcut("r") .disabled(controller.isRefreshing) } @@ -906,11 +981,16 @@ struct PopoverView: View { .frame(height: 24) .background(active ? Color.razerGreen : Color.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 6)) + .hoverHighlight(RoundedRectangle(cornerRadius: 6)) .contextMenu { if supportsBasiliskBluetooth { Button("Edit DPI") { beginDpiStageEdit(index: index, value: value) } } } + // The stage editor lives in a context menu, which nothing on the chip hints at. + .help(supportsBasiliskBluetooth ? "Stage \(index + 1) · Right-click to edit" : "Stage \(index + 1)") + .accessibilityLabel("\(value) DPI, stage \(index + 1)") + .accessibilityAddTraits(active ? .isSelected : []) .disabled(controller.isUpdatingDpiStages || (editingDpiStage != nil && !editing)) } @@ -960,7 +1040,7 @@ struct PopoverView: View { .disabled(controller.sleepTimeout == nil) HStack(spacing: 5) { ForEach([60, 120, 300, 600, 900], id: \.self) { value in - Button(formatSleepTimeout(value)) { + Button(formatSleepTimeout(value, compact: true)) { sleepTimeoutValue = Double(value) controller.setSleepTimeout(value) } @@ -969,6 +1049,8 @@ struct PopoverView: View { .foregroundStyle(Int(sleepTimeoutValue) == value ? .white : .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)) + .accessibilityLabel("Sleep after \(formatSleepTimeout(value))") } } .disabled(controller.sleepTimeout == nil) @@ -979,8 +1061,12 @@ struct PopoverView: View { } } - private func formatSleepTimeout(_ seconds: Int) -> String { - "\(seconds / 60)m \(String(format: "%02d", seconds % 60))s" + /// "5 min" or "4 min 45 s", the way System Settings words its sleep timers, instead of + /// "5m 00s". `compact` is for the preset chips, five to a row: "5m". + private func formatSleepTimeout(_ seconds: Int, compact: Bool = false) -> String { + let minutes = seconds / 60, rest = seconds % 60 + if compact { return rest == 0 ? "\(minutes)m" : "\(minutes)m\(rest)s" } + return rest == 0 ? "\(minutes) min" : "\(minutes) min \(rest) s" } /// The custom DPI chip — shows the saved manual value, green outline to mark it as the @@ -1002,6 +1088,10 @@ struct PopoverView: View { in: RoundedRectangle(cornerRadius: 6)) .overlay(RoundedRectangle(cornerRadius: 6) .stroke(active ? Color.clear : Color.razerGreen.opacity(0.55), lineWidth: 1)) + .hoverHighlight(RoundedRectangle(cornerRadius: 6)) + .help("Your custom DPI. Set it with the slider.") + .accessibilityLabel("Custom, \(active ? Int(dpiValue) : customDPI) DPI") + .accessibilityAddTraits(active ? .isSelected : []) } private var pollCard: some View { @@ -1105,8 +1195,8 @@ struct PopoverView: View { if controller.effect == .staticColor || !controller.supportsLightingEffects { HStack(spacing: 7) { - ForEach(Array(swatches.enumerated()), id: \.offset) { _, sw in - swatch(sw) + ForEach(Array(swatches.enumerated()), id: \.offset) { index, sw in + swatch(sw, name: swatchNames[index]) } customColorWell Spacer(minLength: 0) @@ -1138,13 +1228,21 @@ struct PopoverView: View { && Int(brightnessValue) > Battery.lightingHintBrightnessPercent } - private func swatch(_ sw: Color) -> some View { - Circle() - .fill(sw) - .frame(width: 18, height: 18) - .overlay(Circle().strokeBorder(Color.primary.opacity(0.15), lineWidth: 0.5)) - .overlay(Circle().inset(by: -2.5).stroke(Color.primary, lineWidth: isSelected(sw) ? 1.5 : 0)) - .onTapGesture { controller.setStaticColor(sw.rgb) } + /// A real button rather than a tap gesture on a circle, so it reaches VoiceOver and + /// keyboard navigation, and greys out with the rest of the card while disconnected. + private func swatch(_ sw: Color, name: String) -> some View { + Button { controller.setStaticColor(sw.rgb) } label: { + Circle() + .fill(sw) + .frame(width: 18, height: 18) + .overlay(Circle().strokeBorder(Color.primary.opacity(0.15), lineWidth: 0.5)) + .overlay(Circle().inset(by: -2.5).stroke(Color.primary, lineWidth: isSelected(sw) ? 1.5 : 0)) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .help(name) + .accessibilityLabel(name) + .accessibilityAddTraits(isSelected(sw) ? .isSelected : []) } /// Circular "custom colour" well — a rainbow ring that opens the system colour panel. @@ -1166,6 +1264,8 @@ struct PopoverView: View { .overlay(Circle().strokeBorder(Color.primary.opacity(0.15), lineWidth: 0.5)) } .buttonStyle(.plain) + .help("Pick any colour") + .accessibilityLabel("Custom colour") } private func isSelected(_ sw: Color) -> Bool { @@ -1186,6 +1286,7 @@ struct PopoverView: View { .padding(12) .frame(maxWidth: .infinity) .background(Color.primary.opacity(0.10), in: RoundedRectangle(cornerRadius: 13)) + .hoverHighlight(RoundedRectangle(cornerRadius: 13)) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -1223,7 +1324,10 @@ struct PopoverView: View { } .buttonStyle(.plain) .onHover { gearHovered = $0 } - .help("MacRazer settings") + .help("MacRazer settings (⌘,)") + .accessibilityLabel("Settings") + // The same shortcut as the right-click menu's "Settings…", and every Mac app's. + .keyboardShortcut(",") .padding(.trailing, 6) Button("Quit") { NSApplication.shared.terminate(nil) } .buttonStyle(.borderless) diff --git a/Sources/MacRazer/ProfilesView.swift b/Sources/MacRazer/ProfilesView.swift index ea090d1..2d737f9 100644 --- a/Sources/MacRazer/ProfilesView.swift +++ b/Sources/MacRazer/ProfilesView.swift @@ -51,7 +51,8 @@ struct ProfilesView: View { private var header: some View { VStack(alignment: .leading, spacing: 8) { - if let onBack { BackButton(action: onBack) } + // Esc cancels an in-progress rename (the field's onExitCommand), not the page. + if let onBack { BackButton(action: onBack, escapes: renamingID == nil) } VStack(alignment: .leading, spacing: 2) { Text("Profiles").font(.system(size: 16, weight: .semibold)) Text("Saved DPI, lighting and button setups you can switch between.") diff --git a/Sources/MacRazer/RemapView.swift b/Sources/MacRazer/RemapView.swift index ab6e738..2849694 100644 --- a/Sources/MacRazer/RemapView.swift +++ b/Sources/MacRazer/RemapView.swift @@ -112,7 +112,10 @@ struct RemapView: View { private var header: some View { VStack(alignment: .leading, spacing: 8) { - if let onBack { BackButton(action: onBack) } + // Esc belongs to the key recorder while it's up: it cancels the recording. + if let onBack { + BackButton(action: onBack, escapes: recordingButton == nil && !recordingDpiCycleShortcut) + } VStack(alignment: .leading, spacing: 2) { Text("Configure Buttons").font(.system(size: 16, weight: .semibold)) Text("Remap your mouse's extra buttons.") diff --git a/Sources/MacRazer/WhatsNewPage.swift b/Sources/MacRazer/WhatsNewPage.swift index 28343a2..5e09552 100644 --- a/Sources/MacRazer/WhatsNewPage.swift +++ b/Sources/MacRazer/WhatsNewPage.swift @@ -26,11 +26,9 @@ struct WhatsNewPage: View { var body: some View { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 8) { - Button(action: onBack) { - Image(systemName: "chevron.left").font(.system(size: 12, weight: .semibold)) - } - .buttonStyle(.plain) - .foregroundStyle(Color.razerGreen) + // The same back control as every other sub-page, so navigation looks and + // behaves (Esc included) the same wherever the popover is. + BackButton(action: onBack) Text("What's new in \(version)").font(.system(size: 13, weight: .semibold)) Spacer(minLength: 0) } diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index 5ae1658..1adcf7a 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -244,6 +244,14 @@ page is a `ScrollView` (so long content scrolls without resizing the popover): - **Settings card**: a switch toggle for "Show battery % in menu bar". - Live mouse-config sections **grey out + disable when disconnected** (battery stays readable; refresh stays active). + - With **no mouse known at all** (none seen yet, or unplugged), the controls are replaced + by one card that says how to connect and offers "Look Again" (or the Input Monitoring + grant, when that's what is missing). A last battery reading is still shown if there is one. + - Keyboard: **⌘R** refreshes, **⌘,** opens Settings, and **Esc** on any sub-page goes back + one level instead of closing the popover (except while a key recording or a rename is in + progress, where Esc cancels that). + - Swatches, chips and the icon buttons carry VoiceOver labels and tooltips; chips and + rows get a hover highlight. - **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`. From 43a874ba32bf7cd7922fda00e0608ec4d59f4621 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:27:55 +0000 Subject: [PATCH 2/2] Fix review findings on the popover UX pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ⌘R is one hidden button on the main page, so it works whichever cards are showing (a battery-less mouse has no refresh button). - The no-mouse card also shows under a saved battery reading; only one Input Monitoring button appears there. - Chip labels fill their chip, so the whole hover-highlighted area is clickable (DPI, custom DPI, sleep timer, profiles). - Swatches carry their own names; an editing DPI stage reads "Stage N DPI" to VoiceOver; Back's tooltip mentions Esc only when Esc works. - Drop a comment that said Configure Buttons stays enabled offline. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SJ8Eskh7E8TDXYvX2yS9yZ --- Sources/MacRazer/ColorPickerPage.swift | 2 +- Sources/MacRazer/PopoverView.swift | 69 +++++++++++++++++--------- 2 files changed, 46 insertions(+), 25 deletions(-) diff --git a/Sources/MacRazer/ColorPickerPage.swift b/Sources/MacRazer/ColorPickerPage.swift index 9ecc215..e3713f5 100644 --- a/Sources/MacRazer/ColorPickerPage.swift +++ b/Sources/MacRazer/ColorPickerPage.swift @@ -24,7 +24,7 @@ struct BackButton: View { } .buttonStyle(.plain) .keyboardShortcut(escapes ? KeyboardShortcut.cancelAction : nil) - .help("Back (Esc)") + .help(escapes ? "Back (Esc)" : "Back") } } diff --git a/Sources/MacRazer/PopoverView.swift b/Sources/MacRazer/PopoverView.swift index 4aedbe4..48bd23c 100644 --- a/Sources/MacRazer/PopoverView.swift +++ b/Sources/MacRazer/PopoverView.swift @@ -66,18 +66,17 @@ struct PopoverView: View { private var displayedStages: [Int] { controller.dpiStages.isEmpty ? defaultStages : controller.dpiStages } - /// Spoken and tooltip names for `swatches`, in the same order. - private let swatchNames = ["Red", "Orange", "Yellow", "Green", "Cyan", "Blue", "Purple", "Pink"] // Distinct hues with a true red (SwiftUI .red is a warm orange-red; .green duplicated razerGreen). - private let swatches: [Color] = [ - Color(red: 1.0, green: 0.0, blue: 0.0), // true red - Color(red: 1.0, green: 0.5, blue: 0.0), // orange - Color(red: 1.0, green: 0.85, blue: 0.0), // yellow - .razerGreen, // green - Color(red: 0.0, green: 0.8, blue: 1.0), // cyan - Color(red: 0.15, green: 0.35, blue: 1.0), // blue - Color(red: 0.6, green: 0.2, blue: 1.0), // purple - Color(red: 1.0, green: 0.2, blue: 0.65), // pink + // Each carries its spoken/tooltip name. + private let swatches: [(name: String, color: Color)] = [ + ("Red", Color(red: 1.0, green: 0.0, blue: 0.0)), + ("Orange", Color(red: 1.0, green: 0.5, blue: 0.0)), + ("Yellow", Color(red: 1.0, green: 0.85, blue: 0.0)), + ("Green", .razerGreen), + ("Cyan", Color(red: 0.0, green: 0.8, blue: 1.0)), + ("Blue", Color(red: 0.15, green: 0.35, blue: 1.0)), + ("Purple", Color(red: 0.6, green: 0.2, blue: 1.0)), + ("Pink", Color(red: 1.0, green: 0.2, blue: 0.65)), ] /// Popover sizes to the MAIN page content (no empty space); sub-pages match that height @@ -163,6 +162,18 @@ struct PopoverView: View { } } + /// ⌘R — the same shortcut the right-click menu's "Refresh Now" shows. Bound once for the + /// whole main page rather than to a control inside one card, so it works whichever cards + /// are showing (a mouse without a battery has no battery card and no refresh button). + private var refreshShortcut: some View { + Button("Refresh") { controller.refreshAll() } + .keyboardShortcut("r") + .disabled(controller.isRefreshing) + .opacity(0) + .frame(width: 0, height: 0) + .accessibilityHidden(true) + } + private var mainPage: some View { VStack(alignment: .leading, spacing: 10) { headerCard @@ -174,10 +185,10 @@ struct PopoverView: View { // explain it instead of just showing "offline". if controller.bluetoothMouse != nil && !controller.connected { bluetoothNotice } if noMouse { - // A last reading is still worth showing; with none, the battery card would be - // a "—" and nothing else, so the empty state says what to do instead. + // A last reading is still worth showing (the snapshot is persisted, so most + // returning users have one); the empty-state card still says what to do. if controller.lastBatterySnapshot != nil { batteryCard.opacity(0.55) } - else { noMouseCard } + noMouseCard } else { // Battery stays readable (last-known) but dims when offline; its refresh button // stays active so you can retry. @@ -194,7 +205,7 @@ struct PopoverView: View { .disabled(!controller.connected) .opacity(controller.connected ? 1 : 0.45) - configureButton // software remap — works offline, stays enabled + configureButton // Profiles bundle the sections above (plus remaps) into presets — placed after // them so the page reads "here are the controls, here's how to save/recall them". if controller.supportsProfiles { @@ -205,6 +216,7 @@ struct PopoverView: View { } .padding(12) .frame(width: popoverWidth) + .background(refreshShortcut) .background(GeometryReader { g in Color.clear.preference(key: HeightKey.self, value: g.size.height) }) .onAppear { if controller.dpi != 0 { dpiValue = Double(controller.dpi) } @@ -271,7 +283,6 @@ struct PopoverView: View { .buttonStyle(.bordered) .controlSize(.small) .disabled(controller.isRefreshing) - .keyboardShortcut("r") } } .frame(maxWidth: .infinity) @@ -494,11 +505,13 @@ struct PopoverView: View { controller.applyProfile(profile, remapper: remapper) } label: { Text(profile.name) + .padding(.horizontal, 10) + .frame(maxHeight: .infinity) + .contentShape(Capsule()) } .buttonStyle(.plain) .font(.system(size: 10.5, weight: active ? .semibold : .regular)) .foregroundStyle(active ? .white : .secondary) - .padding(.horizontal, 10) .frame(height: 24) .background(active ? Color.razerGreen : Color.primary.opacity(0.08), in: Capsule()) .hoverHighlight(Capsule()) @@ -803,7 +816,8 @@ struct PopoverView: View { } else { batterySubtitleText } - if needsPermission { + // With no mouse at all, `noMouseCard` below carries this button instead. + if needsPermission && !noMouse { Button { AppDelegate.openInputMonitoringSettings() } label: { @@ -911,8 +925,6 @@ struct PopoverView: View { .foregroundStyle(.secondary) .help(controller.supportsPollRate ? "Refresh battery, DPI and polling rate (⌘R)" : "Refresh battery and DPI (⌘R)") .accessibilityLabel("Refresh") - // The same shortcut the right-click menu's "Refresh Now" shows. - .keyboardShortcut("r") .disabled(controller.isRefreshing) } @@ -969,7 +981,10 @@ struct PopoverView: View { dpiValue = Double(value) controller.setDPI(value) } label: { + // Fill the chip so the whole hover-highlighted area is clickable. Text(verbatim: "\(value)") + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) } .buttonStyle(.plain) } @@ -989,7 +1004,7 @@ struct PopoverView: View { } // The stage editor lives in a context menu, which nothing on the chip hints at. .help(supportsBasiliskBluetooth ? "Stage \(index + 1) · Right-click to edit" : "Stage \(index + 1)") - .accessibilityLabel("\(value) DPI, stage \(index + 1)") + .accessibilityLabel(editing ? "Stage \(index + 1) DPI" : "\(value) DPI, stage \(index + 1)") .accessibilityAddTraits(active ? .isSelected : []) .disabled(controller.isUpdatingDpiStages || (editingDpiStage != nil && !editing)) } @@ -1040,9 +1055,13 @@ struct PopoverView: View { .disabled(controller.sleepTimeout == nil) HStack(spacing: 5) { ForEach([60, 120, 300, 600, 900], id: \.self) { value in - Button(formatSleepTimeout(value, compact: true)) { + Button { sleepTimeoutValue = Double(value) controller.setSleepTimeout(value) + } label: { + Text(formatSleepTimeout(value, compact: true)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) } .buttonStyle(.plain) .font(.system(size: 9.5, weight: Int(sleepTimeoutValue) == value ? .semibold : .regular).monospacedDigit()) @@ -1078,6 +1097,8 @@ struct PopoverView: View { controller.setDPI(customDPI) } label: { Text(verbatim: "\(active ? Int(dpiValue) : customDPI)") + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) } .buttonStyle(.plain) .font(.system(size: 10.5, weight: active ? .semibold : .regular).monospacedDigit()) @@ -1195,8 +1216,8 @@ struct PopoverView: View { if controller.effect == .staticColor || !controller.supportsLightingEffects { HStack(spacing: 7) { - ForEach(Array(swatches.enumerated()), id: \.offset) { index, sw in - swatch(sw, name: swatchNames[index]) + ForEach(swatches.indices, id: \.self) { i in + swatch(swatches[i].color, name: swatches[i].name) } customColorWell Spacer(minLength: 0)