diff --git a/CHANGELOG.md b/CHANGELOG.md index 01a0de2..55e9aa7 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.** 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 + 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/CardComponents.swift b/Sources/MacRazer/CardComponents.swift index e59758b..6bb4f9e 100644 --- a/Sources/MacRazer/CardComponents.swift +++ b/Sources/MacRazer/CardComponents.swift @@ -6,10 +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) - .background(Color.primary.opacity(0.10), in: RoundedRectangle(cornerRadius: 13)) + .frame(maxWidth: .infinity, alignment: .leading)) +} + +/// 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 48bd23c..32a9b4d 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. @@ -196,21 +201,14 @@ struct PopoverView: View { 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 - // 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) - } + 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 } @@ -235,6 +233,14 @@ 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: 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) } } @@ -457,43 +463,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 +1033,54 @@ 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. + /// + /// 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 { + 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 +1116,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 +1145,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 +1174,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 +1220,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 +1289,166 @@ 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, 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 } + private enum GroupRow: Hashable { case section(DisclosureSection), buttons } + /// 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 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 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. + 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 settingsGroup: some View { + let rows = groupRows + // 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 + // 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, + 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: buttonsNeedAccess ? .batteryMid : .secondary, + expanded: false) + } + .buttonStyle(.plain) + .disabled(!controller.connected) + .accessibilityLabel("Configure Buttons") + .accessibilityValue(buttonsSummary) + } + } + .frame(maxWidth: .infinity)) } - .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) + } + + private func disclosureRow(_ section: DisclosureSection, title: String, symbol: String, + tint: Color, value: String, valueColor: Color = .secondary, + @ViewBuilder content: () -> Content) -> some View { + let expanded = expandedSections.contains(section) + return VStack(alignment: .leading, spacing: 0) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { toggle(section) } + } 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() + .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. + .padding(.top, 6) + .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)) + } + // 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(RoundedRectangle(cornerRadius: 8)) + .hoverHighlight(RoundedRectangle(cornerRadius: 8)) + .padding(.horizontal, 4) + .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 { + // 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" } // MARK: Footer diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index 1adcf7a..5a91995 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -237,10 +237,21 @@ 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 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. + - **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, 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. - **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).