From 2583f245cb78be4e811f34ae5a06272568d26a70 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 11:42:28 +0000 Subject: [PATCH 1/5] Collapse the popover's settings into one expandable list Polling rate, Lighting, Power Management, Buttons and Profiles become rows in a single grouped card, Control-Center style. Each row shows its current value; the settings rows expand in place one at a time, and the open one is remembered. Battery and DPI stay open at the top. The battery/lighting tips now only appear inside their open section. Scrolling sub-pages get a 560 pt minimum so a short collapsed main page doesn't squeeze them; a half-typed profile name is dropped when its row closes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SJ8Eskh7E8TDXYvX2yS9yZ --- CHANGELOG.md | 7 + Sources/MacRazer/PopoverView.swift | 345 +++++++++++++++++++---------- docs/DOCUMENTATION.md | 14 +- 3 files changed, 240 insertions(+), 126 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01a0de2..d806919 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ expect rough edges until 1.0. ## [Unreleased] ### Changed +- **The popover is shorter: settings below DPI collapse into one list.** Polling rate, + Lighting, Power Management, Buttons and Profiles are now rows in a single card, each showing + its current value ("1000 Hz", "Static · 80%", the active profile). Click a row to open its + controls in place; one opens at a time, and the popover remembers which. Battery and DPI + stay open at the top, since they're what you check most. The battery and lighting tips now + appear only inside their open section, not on the main page all the time. + - **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 diff --git a/Sources/MacRazer/PopoverView.swift b/Sources/MacRazer/PopoverView.swift index 48bd23c..063059f 100644 --- a/Sources/MacRazer/PopoverView.swift +++ b/Sources/MacRazer/PopoverView.swift @@ -80,8 +80,13 @@ struct PopoverView: View { ] /// Popover sizes to the MAIN page content (no empty space); sub-pages match that height - /// so navigating doesn't resize. Buttons page scrolls if taller; colour page is centred. + /// (but never less than `subPageMinHeight`). Buttons page scrolls if taller; colour page + /// is centred. private let popoverWidth: CGFloat = 320 + /// With its sections collapsed the main page can be short, and sub-pages that scroll (the + /// usage charts, the button list) would be squeezed into it. Below this they grow instead. + /// 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 var body: some View { @@ -96,7 +101,7 @@ struct PopoverView: View { } } .frame(width: popoverWidth) - .frame(height: page == .main ? nil : (mainHeight > 0 ? mainHeight : nil)) + .frame(height: page == .main ? nil : (mainHeight > 0 ? max(mainHeight, page == .color ? 0 : subPageMinHeight) : nil)) // While a sub-page is showing, the main page is unmounted and the preference reverts // to its 0 default — keep the last real measurement so sub-pages stay locked to the // main page's height instead of resizing once the transition finishes. @@ -195,22 +200,15 @@ struct PopoverView: View { if controller.deviceHasBattery { batteryCard.opacity(controller.connected ? 1 : 0.55) } - // Live mouse-config sections: dim AND disable while disconnected. + // Live mouse-config sections: dim AND disable while disconnected. Every row of + // the group below needs the mouse too (the Buttons page included), so the whole + // group dims with DPI. Group { dpiCard - if controller.supportsPollRate { pollCard } - if controller.deviceHasLighting { lightingCard } // hidden for no-LED mice (e.g. Atheris) - if supportsBasiliskBluetooth { powerManagementCard } + settingsGroup } .disabled(!controller.connected) .opacity(controller.connected ? 1 : 0.45) - - 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 { - profilesCard.disabled(!controller.connected).opacity(controller.connected ? 1 : 0.45) - } } footer } @@ -235,6 +233,10 @@ struct PopoverView: View { brightnessValue = Double(controller.brightness) if let timeout = controller.sleepTimeout { sleepTimeoutValue = Double(timeout) } } + // A half-typed profile name shouldn't survive its row closing and reappear later. + .onChange(of: expandedSectionRaw) { _, new in + if new != DisclosureSection.profiles.rawValue { isAddingProfile = false } + } .onChange(of: controller.deviceKey) { _, _ in loadCustomDPI() } // reload/clamp per mouse .onChange(of: controller.deviceMaxDPI) { _, _ in loadCustomDPI() } .onChange(of: controller.sleepTimeout) { _, new in if let new { sleepTimeoutValue = Double(new) } } @@ -457,43 +459,38 @@ struct PopoverView: View { // MARK: Profiles - /// Quick-switch row: a chip per saved profile (filled green when active) plus a "+" chip to - /// save the current setup. Kept on the main page so switching never needs a page navigation — - /// only the rarely-needed rename/delete flow lives behind "Manage…". - private var profilesCard: some View { - card { - VStack(alignment: .leading, spacing: 8) { - HStack { - sectionLabel("Profiles", "person.crop.square.on.square.angled") + /// Quick-switch chips, shown when the Profiles row is expanded: a chip per saved profile + /// (filled green when active) plus a "+" chip to save the current setup. Switching never + /// needs a page navigation; only the rarely-needed rename/delete flow lives behind + /// "Manage Profiles…". + private var profilesContent: some View { + VStack(alignment: .leading, spacing: 8) { + if controller.profiles.isEmpty && !isAddingProfile { + HStack(spacing: 8) { + Text("Save your current setup as a profile") + .font(.system(size: 11)).foregroundStyle(.secondary) Spacer() - if !controller.profiles.isEmpty { - Button("Manage…") { page = .profiles } - .buttonStyle(.plain) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(Color.razerGreen) - } + addProfileChip } - if controller.profiles.isEmpty && !isAddingProfile { - HStack(spacing: 8) { - Text("Save your current setup as a profile") - .font(.system(size: 11)).foregroundStyle(.secondary) - Spacer() - addProfileChip - } - } else { - // Wraps to new rows — a plain HStack overflows the fixed-width popover - // once a handful of profiles exist. - FlowLayout(spacing: 6) { - ForEach(controller.profiles) { profileChip($0) } - if !isAddingProfile { addProfileChip } - } - } - if isAddingProfile { addProfileField } - if controller.profileApplyFailed { - Text("Couldn't apply that. The mouse isn't responding.") - .font(.system(size: 10.5)).foregroundStyle(Color.batteryLow) + } else { + // Wraps to new rows — a plain HStack overflows the fixed-width popover + // once a handful of profiles exist. + FlowLayout(spacing: 6) { + ForEach(controller.profiles) { profileChip($0) } + if !isAddingProfile { addProfileChip } } } + if isAddingProfile { addProfileField } + if controller.profileApplyFailed { + Text("Couldn't apply that. The mouse isn't responding.") + .font(.system(size: 10.5)).foregroundStyle(Color.batteryLow) + } + if !controller.profiles.isEmpty { + Button("Manage Profiles…") { page = .profiles } + .buttonStyle(.plain) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Color.razerGreen) + } } } @@ -1032,54 +1029,51 @@ struct PopoverView: View { @State private var sleepTimeoutValue: Double = 300 - private var powerManagementCard: some View { - card { - VStack(alignment: .leading, spacing: 8) { - HStack { - sectionLabel("Power Management", "moon.zzz") - Spacer() - // Nothing to show until the mouse has reported its value: the slider's - // starting position is a placeholder, not the mouse's setting. - Text(controller.sleepTimeout == nil ? "…" : formatSleepTimeout(Int(sleepTimeoutValue))) - .font(.system(size: 12, weight: .medium)).monospacedDigit() - .foregroundStyle(.secondary) - } - HStack(spacing: 6) { - Image(systemName: "moon.fill").font(.system(size: 10)).foregroundStyle(.secondary) - Slider(value: $sleepTimeoutValue, in: 60...900, step: 15) { editing in - if !editing { controller.setSleepTimeout(Int(sleepTimeoutValue)) } - } - .tint(.razerGreen) - .controlSize(.small) + /// The Power Management row's expanded content; the row itself shows the sleep timer. + private var powerManagementContent: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Image(systemName: "moon.fill").font(.system(size: 10)).foregroundStyle(.secondary) + Slider(value: $sleepTimeoutValue, in: 60...900, step: 15) { editing in + if !editing { controller.setSleepTimeout(Int(sleepTimeoutValue)) } } - .disabled(controller.sleepTimeout == nil) - HStack(spacing: 5) { - ForEach([60, 120, 300, 600, 900], id: \.self) { value in - 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()) - .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))") + .tint(.razerGreen) + .controlSize(.small) + } + .disabled(controller.sleepTimeout == nil) + HStack(spacing: 5) { + ForEach([60, 120, 300, 600, 900], id: \.self) { value in + 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()) + .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) - if let error = controller.sleepTimeoutError { - Text(error).font(.system(size: 10.5)).foregroundStyle(Color.batteryLow) - } + } + .disabled(controller.sleepTimeout == nil) + if let error = controller.sleepTimeoutError { + Text(error).font(.system(size: 10.5)).foregroundStyle(Color.batteryLow) } } } + /// Nothing to show until the mouse has reported its value: the slider's starting position + /// is a placeholder, not the mouse's setting. + private var sleepTimeoutSummary: String { + if controller.sleepTimeoutError != nil { return "Not verified" } + return controller.sleepTimeout == nil ? "…" : formatSleepTimeout(Int(sleepTimeoutValue)) + } + /// "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 { @@ -1115,16 +1109,9 @@ struct PopoverView: View { .accessibilityAddTraits(active ? .isSelected : []) } - private var pollCard: some View { - card { + /// The Polling rate row's expanded content; the row itself shows the current rate. + private var pollContent: some View { VStack(alignment: .leading, spacing: 8) { - HStack { - sectionLabel("Polling rate", "timer") - Spacer() - Text(verbatim: "\(displayedPollRate) Hz") - .font(.system(size: 12, weight: .medium)).monospacedDigit() - .foregroundStyle(.secondary) - } Picker("", selection: Binding( get: { displayedPollRate }, set: { controller.setPollRate($0) } @@ -1151,7 +1138,6 @@ struct PopoverView: View { .transition(.opacity) } } - } } /// The mouse reports 0 when it hasn't answered yet; the card shows that as 1000, the @@ -1181,16 +1167,9 @@ struct PopoverView: View { controller.deviceHasBattery && !controller.charging && controller.pollRate >= 1000 } - private var lightingCard: some View { - card { + /// The Lighting row's expanded content; the row itself shows the effect and brightness. + private var lightingContent: some View { VStack(alignment: .leading, spacing: 8) { - HStack { - sectionLabel("Lighting", "light.max") - Spacer() - Text(verbatim: "\(Int(brightnessValue))%") - .font(.system(size: 12, weight: .medium)).monospacedDigit() - .foregroundStyle(.secondary) - } // Brightness. HStack(spacing: 6) { Image(systemName: "sun.min.fill").font(.system(size: 10)).foregroundStyle(.secondary) @@ -1234,7 +1213,16 @@ struct PopoverView: View { .transition(.opacity) } } - } + } + + /// What the collapsed Lighting row says: the effect and brightness, or just the brightness + /// where effects can't be read (Bluetooth), or "Off". + private var lightingSummary: String { + // 0% draws nothing whatever the effect, the same rule `showsLightingBatteryHint` uses. + if Int(brightnessValue) == 0 { return "Off" } + if !controller.supportsLightingEffects { return "\(Int(brightnessValue))%" } + if controller.effect == .off { return "Off" } + return "\(controller.effect.rawValue) · \(Int(brightnessValue))%" } /// Reads the local slider value, not `controller.brightness`, so the hint tracks the @@ -1294,25 +1282,136 @@ struct PopoverView: View { return a.r == b.r && a.g == b.g && a.b == b.b } - // MARK: Configure buttons + // MARK: Grouped settings + + /// The settings below DPI, one row each in a single grouped card, the way Control Center + /// lists its modules. A row shows its current value; the settings rows expand in place to + /// their controls, one at a time so the popover never grows past one section's worth, and + /// Buttons opens its page. Which section was open is remembered across showings. + private enum DisclosureSection: String { case polling, lighting, power, profiles } + private enum GroupRow: Hashable { case section(DisclosureSection), buttons } + @AppStorage("popoverExpandedSection") private var expandedSectionRaw = "" + private var expandedSection: DisclosureSection? { DisclosureSection(rawValue: expandedSectionRaw) } + + /// Rows in display order, skipping what this mouse can't do. Profiles come last: they + /// bundle the rows above them (plus remaps) into presets. + private var groupRows: [GroupRow] { + var rows: [GroupRow] = [] + if controller.supportsPollRate { rows.append(.section(.polling)) } + if controller.deviceHasLighting { rows.append(.section(.lighting)) } // none on e.g. the Atheris + if supportsBasiliskBluetooth { rows.append(.section(.power)) } + rows.append(.buttons) + if controller.supportsProfiles { rows.append(.section(.profiles)) } + return rows + } - private var configureButton: some View { - Button { page = .buttons } label: { - HStack(spacing: 8) { - Image(systemName: "keyboard").foregroundStyle(Color.razerGreen) - Text("Configure Buttons…").font(.system(size: 12, weight: .medium)) - Spacer() - Image(systemName: "chevron.right").font(.system(size: 10)).foregroundStyle(.tertiary) + private var settingsGroup: some View { + let rows = groupRows + return VStack(spacing: 0) { + // Keyed by row, not position, so a row appearing or vanishing (another mouse, the + // poll rate turning out to be supported) doesn't shift the others' identity. + ForEach(rows, id: \.self) { row in + if row != rows.first { Divider().padding(.leading, 42) } + switch row { + case .section(.polling): + disclosureRow(.polling, title: "Polling rate", symbol: "timer", tint: .blue, + value: "\(displayedPollRate) Hz") { pollContent } + case .section(.lighting): + disclosureRow(.lighting, title: "Lighting", symbol: "light.max", tint: .orange, + value: lightingSummary) { lightingContent } + case .section(.power): + disclosureRow(.power, title: "Power Management", symbol: "moon.zzz", tint: .indigo, + value: sleepTimeoutSummary, + valueColor: controller.sleepTimeoutError == nil ? .secondary : .batteryLow) { + powerManagementContent + } + case .section(.profiles): + disclosureRow(.profiles, title: "Profiles", symbol: "person.crop.square.on.square.angled", + tint: .purple, value: profilesSummary, + valueColor: controller.profileApplyFailed ? .batteryLow : .secondary) { + profilesContent + } + case .buttons: + Button { page = .buttons } label: { + groupRowLabel(title: "Buttons", symbol: "keyboard", tint: .razerGreen, + value: buttonsSummary, valueColor: .secondary, expanded: false) + } + .buttonStyle(.plain) + .accessibilityLabel("Configure Buttons") + .accessibilityValue(buttonsSummary) + } } - .padding(12) - .frame(maxWidth: .infinity) - .background(Color.primary.opacity(0.10), in: RoundedRectangle(cornerRadius: 13)) - .hoverHighlight(RoundedRectangle(cornerRadius: 13)) - .contentShape(Rectangle()) } - .buttonStyle(.plain) - .disabled(!controller.connected) - .opacity(controller.connected ? 1 : 0.45) + .frame(maxWidth: .infinity) + .background(Color.primary.opacity(0.10), in: RoundedRectangle(cornerRadius: 13)) + // Keeps the rows' hover wash inside the card's rounded corners. + .clipShape(RoundedRectangle(cornerRadius: 13)) + } + + private func disclosureRow(_ section: DisclosureSection, title: String, symbol: String, + tint: Color, value: String, valueColor: Color = .secondary, + @ViewBuilder content: () -> Content) -> some View { + let expanded = expandedSection == section + return VStack(alignment: .leading, spacing: 0) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + expandedSectionRaw = expanded ? "" : section.rawValue + } + } label: { + groupRowLabel(title: title, symbol: symbol, tint: tint, value: value, + valueColor: valueColor, expanded: expanded) + } + .buttonStyle(.plain) + .accessibilityLabel(title) + .accessibilityValue(value) + .accessibilityHint(expanded ? "Collapses the \(title) settings" : "Shows the \(title) settings") + if expanded { + content() + .padding(.horizontal, 12) + .padding(.bottom, 12) + .transition(.opacity) + } + } + } + + /// One row of the group: a tinted icon, the name, the current value and a chevron that + /// turns down while the row is expanded. + private func groupRowLabel(title: String, symbol: String, tint: Color, value: String, + valueColor: Color, expanded: Bool) -> some View { + HStack(spacing: 8) { + Image(systemName: symbol) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(tint) + .frame(width: 22, height: 22) + .background(tint.opacity(0.22), in: RoundedRectangle(cornerRadius: 6)) + Text(title).font(.system(size: 12, weight: .medium)) + Spacer(minLength: 6) + Text(value) + .font(.system(size: 11.5)).monospacedDigit() + .foregroundStyle(valueColor) + .lineLimit(1) + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.tertiary) + .rotationEffect(.degrees(expanded ? 90 : 0)) + } + .padding(.horizontal, 12) + .padding(.vertical, 9) + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + .hoverHighlight(Rectangle()) + } + + private var buttonsSummary: String { + // Resetting a button to "None (default)" stores `.passthrough` rather than removing it. + let count = remapper.mappings.values.filter { $0 != .passthrough }.count + return count == 0 ? "" : "\(count) remapped" + } + + private var profilesSummary: String { + if controller.profileApplyFailed { return "Couldn't apply" } + if controller.profiles.isEmpty { return "None saved" } + return controller.profiles.first { $0.id == controller.activeProfileID }?.name ?? "None active" } // MARK: Footer diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index 1adcf7a..7164bff 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -237,10 +237,18 @@ page is a `ScrollView` (so long content scrolls without resizing the popover): bar, the time estimate, and a spinner-animated refresh button. Dims when offline. - **DPI card**: green slider (100-26000) + preset chips (400/800/1600/3200/6400) + a persisted **custom chip** (drag the slider to any value to save it; green-outlined). - - **Polling card**: segmented 125/500/1000 Hz. - - **Lighting card**: brightness slider (☀︎) + effect segmented (Static/Spectrum/Wave/Off) + - **Settings group**: one card listing the rest as rows, Control-Center style. Each row + shows its current value ("1000 Hz", "Static · 80%", "5 min", the active profile) and the + settings rows expand in place, one at a time; the open one is remembered + (`popoverExpandedSection` in UserDefaults). Rows a mouse can't use are left out. + - **Polling rate**: segmented 125/500/1000 Hz, plus what the rate is good for. + - **Lighting**: brightness slider (☀︎) + effect segmented (Static/Spectrum/Wave/Off) + colour swatches (true red...pink) + a rainbow "custom" well that opens the colour wheel. - - **Configure Buttons** card-button to buttons page. + - **Power Management** (Basilisk V3 X over Bluetooth): the sleep timer. + - **Buttons**: opens the buttons page; shows how many buttons are remapped. + - **Profiles**: quick-switch chips, "+" to save, "Manage Profiles…". + - Sub-pages that scroll are at least 560 pt tall, so a short (collapsed) main page doesn't squeeze + them; the colour page keeps the main page's height. - **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). From b226e74f345e89d2d13ae586d94ab8ff91e1bd65 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 17:58:08 +0000 Subject: [PATCH 2/5] Let popover sections open independently; inset the row highlight - Rows no longer auto-collapse each other; the set of open sections is remembered (popoverExpandedSections). - The hover highlight is an inset rounded shape instead of a full-width band that ran past the inset separators; separators are inset on both sides. - 6 pt between a row and its opened controls, so a highlighted header doesn't sit on top of them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SJ8Eskh7E8TDXYvX2yS9yZ --- CHANGELOG.md | 2 +- Sources/MacRazer/PopoverView.swift | 51 +++++++++++++++++++----------- docs/DOCUMENTATION.md | 4 +-- 3 files changed, 36 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d806919..2ea5e3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ expect rough edges until 1.0. - **The popover is shorter: settings below DPI collapse into one list.** Polling rate, Lighting, Power Management, Buttons and Profiles are now rows in a single card, each showing its current value ("1000 Hz", "Static · 80%", the active profile). Click a row to open its - controls in place; one opens at a time, and the popover remembers which. Battery and DPI + controls in place; open as many as you like, and the popover remembers which. Battery and DPI stay open at the top, since they're what you check most. The battery and lighting tips now appear only inside their open section, not on the main page all the time. diff --git a/Sources/MacRazer/PopoverView.swift b/Sources/MacRazer/PopoverView.swift index 063059f..54e6d47 100644 --- a/Sources/MacRazer/PopoverView.swift +++ b/Sources/MacRazer/PopoverView.swift @@ -234,8 +234,8 @@ struct PopoverView: View { if let timeout = controller.sleepTimeout { sleepTimeoutValue = Double(timeout) } } // A half-typed profile name shouldn't survive its row closing and reappear later. - .onChange(of: expandedSectionRaw) { _, new in - if new != DisclosureSection.profiles.rawValue { isAddingProfile = false } + .onChange(of: expandedSections.contains(.profiles)) { _, open in + if !open { isAddingProfile = false } } .onChange(of: controller.deviceKey) { _, _ in loadCustomDPI() } // reload/clamp per mouse .onChange(of: controller.deviceMaxDPI) { _, _ in loadCustomDPI() } @@ -1286,12 +1286,22 @@ struct PopoverView: View { /// The settings below DPI, one row each in a single grouped card, the way Control Center /// lists its modules. A row shows its current value; the settings rows expand in place to - /// their controls, one at a time so the popover never grows past one section's worth, and - /// Buttons opens its page. Which section was open is remembered across showings. - private enum DisclosureSection: String { case polling, lighting, power, profiles } + /// their controls, each independently of the others, and Buttons opens its page. Which + /// sections are open is remembered across showings. + private enum DisclosureSection: String, CaseIterable { case polling, lighting, power, profiles } private enum GroupRow: Hashable { case section(DisclosureSection), buttons } - @AppStorage("popoverExpandedSection") private var expandedSectionRaw = "" - private var expandedSection: DisclosureSection? { DisclosureSection(rawValue: expandedSectionRaw) } + /// The open sections, comma-separated: `@AppStorage` can't hold a set. + @AppStorage("popoverExpandedSections") private var expandedSectionsRaw = "" + private var expandedSections: Set { + Set(expandedSectionsRaw.split(separator: ",").compactMap { DisclosureSection(rawValue: String($0)) }) + } + + private func toggle(_ section: DisclosureSection) { + var open = expandedSections + if open.contains(section) { open.remove(section) } else { open.insert(section) } + // Written in declaration order so the stored string is stable. + expandedSectionsRaw = DisclosureSection.allCases.filter { open.contains($0) }.map(\.rawValue).joined(separator: ",") + } /// Rows in display order, skipping what this mouse can't do. Profiles come last: they /// bundle the rows above them (plus remaps) into presets. @@ -1311,7 +1321,8 @@ struct PopoverView: View { // Keyed by row, not position, so a row appearing or vanishing (another mouse, the // poll rate turning out to be supported) doesn't shift the others' identity. ForEach(rows, id: \.self) { row in - if row != rows.first { Divider().padding(.leading, 42) } + // Inset on both sides, like a macOS list separator. + if row != rows.first { Divider().padding(.leading, 42).padding(.trailing, 12) } switch row { case .section(.polling): disclosureRow(.polling, title: "Polling rate", symbol: "timer", tint: .blue, @@ -1344,19 +1355,15 @@ struct PopoverView: View { } .frame(maxWidth: .infinity) .background(Color.primary.opacity(0.10), in: RoundedRectangle(cornerRadius: 13)) - // Keeps the rows' hover wash inside the card's rounded corners. - .clipShape(RoundedRectangle(cornerRadius: 13)) } private func disclosureRow(_ section: DisclosureSection, title: String, symbol: String, tint: Color, value: String, valueColor: Color = .secondary, @ViewBuilder content: () -> Content) -> some View { - let expanded = expandedSection == section + let expanded = expandedSections.contains(section) return VStack(alignment: .leading, spacing: 0) { Button { - withAnimation(.easeInOut(duration: 0.2)) { - expandedSectionRaw = expanded ? "" : section.rawValue - } + withAnimation(.easeInOut(duration: 0.2)) { toggle(section) } } label: { groupRowLabel(title: title, symbol: symbol, tint: tint, value: value, valueColor: valueColor, expanded: expanded) @@ -1368,6 +1375,9 @@ struct PopoverView: View { if expanded { content() .padding(.horizontal, 12) + // Clear of the header, so a still-highlighted header doesn't sit right on + // top of the controls it opened. + .padding(.top, 6) .padding(.bottom, 12) .transition(.opacity) } @@ -1395,11 +1405,16 @@ struct PopoverView: View { .foregroundStyle(.tertiary) .rotationEffect(.degrees(expanded ? 90 : 0)) } - .padding(.horizontal, 12) - .padding(.vertical, 9) + // The hover wash is an inset rounded shape, like a menu item's or a Control Center + // row's, rather than an edge-to-edge band: a full-width band ran past the inset + // separators and read as unfinished. Same 12 × 9 overall padding as before. + .padding(.horizontal, 8) + .padding(.vertical, 7) .frame(maxWidth: .infinity) - .contentShape(Rectangle()) - .hoverHighlight(Rectangle()) + .contentShape(RoundedRectangle(cornerRadius: 8)) + .hoverHighlight(RoundedRectangle(cornerRadius: 8)) + .padding(.horizontal, 4) + .padding(.vertical, 2) } private var buttonsSummary: String { diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index 7164bff..c38c15f 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -239,8 +239,8 @@ page is a `ScrollView` (so long content scrolls without resizing the popover): persisted **custom chip** (drag the slider to any value to save it; green-outlined). - **Settings group**: one card listing the rest as rows, Control-Center style. Each row shows its current value ("1000 Hz", "Static · 80%", "5 min", the active profile) and the - settings rows expand in place, one at a time; the open one is remembered - (`popoverExpandedSection` in UserDefaults). Rows a mouse can't use are left out. + settings rows expand in place, each on its own; which are open is remembered + (`popoverExpandedSections` in UserDefaults). Rows a mouse can't use are left out. - **Polling rate**: segmented 125/500/1000 Hz, plus what the rate is good for. - **Lighting**: brightness slider (☀︎) + effect segmented (Static/Spectrum/Wave/Off) + colour swatches (true red...pink) + a rainbow "custom" well that opens the colour wheel. From 833151083ec56632e7ad0b485245edeef133cf03 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 18:22:09 +0000 Subject: [PATCH 3/5] Give each popover section its own card Polling rate, Lighting, Power Management, Buttons and Profiles each sit in their own card, 10 pt apart like the cards above, instead of one card split by separators, so an open section's controls are clearly bounded. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SJ8Eskh7E8TDXYvX2yS9yZ --- CHANGELOG.md | 4 +- Sources/MacRazer/PopoverView.swift | 69 ++++++++++++++++-------------- docs/DOCUMENTATION.md | 2 +- 3 files changed, 39 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ea5e3f..55e9aa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,8 @@ expect rough edges until 1.0. ## [Unreleased] ### Changed -- **The popover is shorter: settings below DPI collapse into one list.** Polling rate, - Lighting, Power Management, Buttons and Profiles are now rows in a single card, each showing +- **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 controls in place; open as many as you like, and the popover remembers which. Battery and DPI stay open at the top, since they're what you check most. The battery and lighting tips now diff --git a/Sources/MacRazer/PopoverView.swift b/Sources/MacRazer/PopoverView.swift index 54e6d47..80e35b5 100644 --- a/Sources/MacRazer/PopoverView.swift +++ b/Sources/MacRazer/PopoverView.swift @@ -1284,8 +1284,8 @@ struct PopoverView: View { // MARK: Grouped settings - /// The settings below DPI, one row each in a single grouped card, the way Control Center - /// lists its modules. A row shows its current value; the settings rows expand in place to + /// The settings below DPI, each a compact card of its own, the way Control Center lists + /// its modules. A row shows its current value; the settings rows expand in place to /// their controls, each independently of the others, and Buttons opens its page. Which /// sections are open is remembered across showings. private enum DisclosureSection: String, CaseIterable { case polling, lighting, power, profiles } @@ -1317,44 +1317,47 @@ struct PopoverView: View { private var settingsGroup: some View { let rows = groupRows - return VStack(spacing: 0) { + // Same 10 pt rhythm as the cards above, so each section reads as its own card. + return VStack(spacing: 10) { // Keyed by row, not position, so a row appearing or vanishing (another mouse, the // poll rate turning out to be supported) doesn't shift the others' identity. ForEach(rows, id: \.self) { row in - // Inset on both sides, like a macOS list separator. - if row != rows.first { Divider().padding(.leading, 42).padding(.trailing, 12) } - switch row { - case .section(.polling): - disclosureRow(.polling, title: "Polling rate", symbol: "timer", tint: .blue, - value: "\(displayedPollRate) Hz") { pollContent } - case .section(.lighting): - disclosureRow(.lighting, title: "Lighting", symbol: "light.max", tint: .orange, - value: lightingSummary) { lightingContent } - case .section(.power): - disclosureRow(.power, title: "Power Management", symbol: "moon.zzz", tint: .indigo, - value: sleepTimeoutSummary, - valueColor: controller.sleepTimeoutError == nil ? .secondary : .batteryLow) { - powerManagementContent - } - case .section(.profiles): - disclosureRow(.profiles, title: "Profiles", symbol: "person.crop.square.on.square.angled", - tint: .purple, value: profilesSummary, - valueColor: controller.profileApplyFailed ? .batteryLow : .secondary) { - profilesContent - } - case .buttons: - Button { page = .buttons } label: { - groupRowLabel(title: "Buttons", symbol: "keyboard", tint: .razerGreen, - value: buttonsSummary, valueColor: .secondary, expanded: false) + Group { + switch row { + case .section(.polling): + disclosureRow(.polling, title: "Polling rate", symbol: "timer", tint: .blue, + value: "\(displayedPollRate) Hz") { pollContent } + case .section(.lighting): + disclosureRow(.lighting, title: "Lighting", symbol: "light.max", tint: .orange, + value: lightingSummary) { lightingContent } + case .section(.power): + disclosureRow(.power, title: "Power Management", symbol: "moon.zzz", tint: .indigo, + value: sleepTimeoutSummary, + valueColor: controller.sleepTimeoutError == nil ? .secondary : .batteryLow) { + powerManagementContent + } + case .section(.profiles): + disclosureRow(.profiles, title: "Profiles", symbol: "person.crop.square.on.square.angled", + tint: .purple, value: profilesSummary, + valueColor: controller.profileApplyFailed ? .batteryLow : .secondary) { + profilesContent + } + case .buttons: + Button { page = .buttons } label: { + groupRowLabel(title: "Buttons", symbol: "keyboard", tint: .razerGreen, + value: buttonsSummary, valueColor: .secondary, expanded: false) + } + .buttonStyle(.plain) + .accessibilityLabel("Configure Buttons") + .accessibilityValue(buttonsSummary) } - .buttonStyle(.plain) - .accessibilityLabel("Configure Buttons") - .accessibilityValue(buttonsSummary) } + // Each row is its own card, open or closed, rather than one card split by + // separators: an open section's controls stay clearly inside their row. + .frame(maxWidth: .infinity) + .background(Color.primary.opacity(0.10), in: RoundedRectangle(cornerRadius: 13)) } } - .frame(maxWidth: .infinity) - .background(Color.primary.opacity(0.10), in: RoundedRectangle(cornerRadius: 13)) } private func disclosureRow(_ section: DisclosureSection, title: String, symbol: String, diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index c38c15f..504a691 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -237,7 +237,7 @@ page is a `ScrollView` (so long content scrolls without resizing the popover): bar, the time estimate, and a spinner-animated refresh button. Dims when offline. - **DPI card**: green slider (100-26000) + preset chips (400/800/1600/3200/6400) + a persisted **custom chip** (drag the slider to any value to save it; green-outlined). - - **Settings group**: one card listing the rest as rows, Control-Center style. Each row + - **Settings sections**: the rest, each a compact card of its own, Control-Center style. Each shows its current value ("1000 Hz", "Static · 80%", "5 min", the active profile) and the settings rows expand in place, each on its own; which are open is remembered (`popoverExpandedSections` in UserDefaults). Rows a mouse can't use are left out. From e37bc4b059c0b142a75d6550195f8e7ac8a1c95e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 18:34:26 +0000 Subject: [PATCH 4/5] Address review findings on the popover sections - Section rows open offline so last-known settings stay readable; only their controls and the Buttons page are disabled. - The sleep timer and Profiles rows keep showing the real value after a failed write, in red, instead of replacing it with an error that could stick for the session. - Buttons says "Needs access" when saved remaps can't fire without Accessibility permission. - Drop a half-typed profile name when the Profiles row disappears. - Share the card surface (cardSurface) between card() and the rows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SJ8Eskh7E8TDXYvX2yS9yZ --- Sources/MacRazer/CardComponents.swift | 10 +++++- Sources/MacRazer/PopoverView.swift | 48 +++++++++++++++++++-------- docs/DOCUMENTATION.md | 5 ++- 3 files changed, 47 insertions(+), 16 deletions(-) diff --git a/Sources/MacRazer/CardComponents.swift b/Sources/MacRazer/CardComponents.swift index e59758b..7e73a81 100644 --- a/Sources/MacRazer/CardComponents.swift +++ b/Sources/MacRazer/CardComponents.swift @@ -9,7 +9,15 @@ func card(@ViewBuilder _ content: () -> Content) -> some View { content() .padding(12) .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.primary.opacity(0.10), in: RoundedRectangle(cornerRadius: 13)) + .cardSurface() +} + +extension View { + /// The card's fill and shape without its padding, for cards that lay out their own insets + /// (the popover's expandable section rows). + func cardSurface() -> some View { + background(Color.primary.opacity(0.10), in: RoundedRectangle(cornerRadius: 13)) + } } /// A card with a small uppercase heading — the window-sized counterpart to `card`, shared by diff --git a/Sources/MacRazer/PopoverView.swift b/Sources/MacRazer/PopoverView.swift index 80e35b5..c44f84c 100644 --- a/Sources/MacRazer/PopoverView.swift +++ b/Sources/MacRazer/PopoverView.swift @@ -200,15 +200,15 @@ struct PopoverView: View { if controller.deviceHasBattery { batteryCard.opacity(controller.connected ? 1 : 0.55) } - // Live mouse-config sections: dim AND disable while disconnected. Every row of - // the group below needs the mouse too (the Buttons page included), so the whole - // group dims with DPI. - Group { - dpiCard - settingsGroup - } - .disabled(!controller.connected) - .opacity(controller.connected ? 1 : 0.45) + // Live mouse-config sections: dim AND disable while disconnected. + dpiCard + .disabled(!controller.connected) + .opacity(controller.connected ? 1 : 0.45) + // Dims with DPI, but its rows still open and close offline so the last-known + // settings stay readable; the controls inside them (and the Buttons page, + // which needs the mouse) are what's disabled. + settingsGroup + .opacity(controller.connected ? 1 : 0.45) } footer } @@ -237,6 +237,10 @@ struct PopoverView: View { .onChange(of: expandedSections.contains(.profiles)) { _, open in if !open { isAddingProfile = false } } + // …or when the row disappears instead of closing (a mouse without profiles). + .onChange(of: controller.supportsProfiles) { _, supported in + if !supported { isAddingProfile = false } + } .onChange(of: controller.deviceKey) { _, _ in loadCustomDPI() } // reload/clamp per mouse .onChange(of: controller.deviceMaxDPI) { _, _ in loadCustomDPI() } .onChange(of: controller.sleepTimeout) { _, new in if let new { sleepTimeoutValue = Double(new) } } @@ -1069,9 +1073,12 @@ struct PopoverView: View { /// Nothing to show until the mouse has reported its value: the slider's starting position /// is a placeholder, not the mouse's setting. + /// + /// A failed write turns the value red (see the row) rather than replacing it: the mouse's + /// last confirmed timeout is still the useful thing to show, and the error itself is in + /// the expanded controls. private var sleepTimeoutSummary: String { - if controller.sleepTimeoutError != nil { return "Not verified" } - return controller.sleepTimeout == nil ? "…" : formatSleepTimeout(Int(sleepTimeoutValue)) + controller.sleepTimeout == nil ? "…" : formatSleepTimeout(Int(sleepTimeoutValue)) } /// "5 min" or "4 min 45 s", the way System Settings words its sleep timers, instead of @@ -1345,9 +1352,12 @@ struct PopoverView: View { case .buttons: Button { page = .buttons } label: { groupRowLabel(title: "Buttons", symbol: "keyboard", tint: .razerGreen, - value: buttonsSummary, valueColor: .secondary, expanded: false) + value: buttonsSummary, + valueColor: buttonsNeedAccess ? .batteryMid : .secondary, + expanded: false) } .buttonStyle(.plain) + .disabled(!controller.connected) .accessibilityLabel("Configure Buttons") .accessibilityValue(buttonsSummary) } @@ -1355,7 +1365,7 @@ struct PopoverView: View { // Each row is its own card, open or closed, rather than one card split by // separators: an open section's controls stay clearly inside their row. .frame(maxWidth: .infinity) - .background(Color.primary.opacity(0.10), in: RoundedRectangle(cornerRadius: 13)) + .cardSurface() } } } @@ -1377,6 +1387,7 @@ struct PopoverView: View { .accessibilityHint(expanded ? "Collapses the \(title) settings" : "Shows the \(title) settings") if expanded { content() + .disabled(!controller.connected) .padding(.horizontal, 12) // Clear of the header, so a still-highlighted header doesn't sit right on // top of the controls it opened. @@ -1420,14 +1431,23 @@ struct PopoverView: View { .padding(.vertical, 2) } + /// Saved remaps do nothing without Accessibility access and the event tap, so the row + /// says so instead of counting them. + private var buttonsNeedAccess: Bool { + !remapper.remappingPermissionsGranted && !remapper.mappings.values.filter { $0 != .passthrough }.isEmpty + } + private var buttonsSummary: String { + if buttonsNeedAccess { return "Needs access" } // Resetting a button to "None (default)" stores `.passthrough` rather than removing it. let count = remapper.mappings.values.filter { $0 != .passthrough }.count return count == 0 ? "" : "\(count) remapped" } private var profilesSummary: String { - if controller.profileApplyFailed { return "Couldn't apply" } + // A failed apply turns this red (see the row) rather than replacing it, so the row + // still says which profile is actually active; the error text is in the expanded + // section. if controller.profiles.isEmpty { return "None saved" } return controller.profiles.first { $0.id == controller.activeProfileID }?.name ?? "None active" } diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index 504a691..5a91995 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -245,7 +245,10 @@ page is a `ScrollView` (so long content scrolls without resizing the popover): - **Lighting**: brightness slider (☀︎) + effect segmented (Static/Spectrum/Wave/Off) + colour swatches (true red...pink) + a rainbow "custom" well that opens the colour wheel. - **Power Management** (Basilisk V3 X over Bluetooth): the sleep timer. - - **Buttons**: opens the buttons page; shows how many buttons are remapped. + - **Buttons**: opens the buttons page; shows how many buttons are remapped, or "Needs access" + when saved remaps can't fire without Accessibility permission. + - Offline, the section rows still open to show last-known settings; their controls and the + Buttons page are disabled. A failed write turns that row's value red. - **Profiles**: quick-switch chips, "+" to save, "Manage Profiles…". - Sub-pages that scroll are at least 560 pt tall, so a short (collapsed) main page doesn't squeeze them; the colour page keeps the main page's height. From 62ea4778eef6a8f13a9f704c08065d0de66083a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 18:36:13 +0000 Subject: [PATCH 5/5] Make cardSurface a free function to fix the Swift 6 build As a View extension it was main-actor isolated, and card() (a nonisolated free function) couldn't call it: "non-Sendable 'some View'-typed result can not be returned from main actor-isolated instance method". A free function, like card() itself, has no isolation, so both call sites compile. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SJ8Eskh7E8TDXYvX2yS9yZ --- Sources/MacRazer/CardComponents.swift | 18 +++++++++--------- Sources/MacRazer/PopoverView.swift | 9 ++++----- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/Sources/MacRazer/CardComponents.swift b/Sources/MacRazer/CardComponents.swift index 7e73a81..6bb4f9e 100644 --- a/Sources/MacRazer/CardComponents.swift +++ b/Sources/MacRazer/CardComponents.swift @@ -6,18 +6,18 @@ import SwiftUI /// Control-Center-style tile: a rounded, lightly-frosted card on the dark popover. Shared by /// every popover sub-page so the card style can't drift between them. func card(@ViewBuilder _ content: () -> Content) -> some View { - content() + cardSurface(content() .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .cardSurface() + .frame(maxWidth: .infinity, alignment: .leading)) } -extension View { - /// The card's fill and shape without its padding, for cards that lay out their own insets - /// (the popover's expandable section rows). - func cardSurface() -> some View { - background(Color.primary.opacity(0.10), in: RoundedRectangle(cornerRadius: 13)) - } +/// The card's fill and shape without its padding, for cards that lay out their own insets (the +/// popover's expandable section rows). +/// +/// A free function like `card`, not a `View` extension: an extension method is main-actor +/// isolated, and `card` (nonisolated) couldn't call it under Swift 6 concurrency checking. +func cardSurface(_ content: Content) -> some View { + content.background(Color.primary.opacity(0.10), in: RoundedRectangle(cornerRadius: 13)) } /// A card with a small uppercase heading — the window-sized counterpart to `card`, shared by diff --git a/Sources/MacRazer/PopoverView.swift b/Sources/MacRazer/PopoverView.swift index c44f84c..32a9b4d 100644 --- a/Sources/MacRazer/PopoverView.swift +++ b/Sources/MacRazer/PopoverView.swift @@ -1329,7 +1329,9 @@ struct PopoverView: View { // Keyed by row, not position, so a row appearing or vanishing (another mouse, the // poll rate turning out to be supported) doesn't shift the others' identity. ForEach(rows, id: \.self) { row in - Group { + // Each row is its own card, open or closed, rather than one card split by + // separators: an open section's controls stay clearly inside their row. + cardSurface(Group { switch row { case .section(.polling): disclosureRow(.polling, title: "Polling rate", symbol: "timer", tint: .blue, @@ -1362,10 +1364,7 @@ struct PopoverView: View { .accessibilityValue(buttonsSummary) } } - // Each row is its own card, open or closed, rather than one card split by - // separators: an open section's controls stay clearly inside their row. - .frame(maxWidth: .infinity) - .cardSurface() + .frame(maxWidth: .infinity)) } } }