Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Menu bar spacing #267

Merged
merged 13 commits into from
Jul 25, 2024
12 changes: 12 additions & 0 deletions Ice.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
17FE5AD32BCA7A7500E4F8D9 /* ScreenStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17FE5AD22BCA7A7500E4F8D9 /* ScreenStateManager.swift */; };
17FE5AD52BCA9BE200E4F8D9 /* ScreenState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17FE5AD42BCA9BE200E4F8D9 /* ScreenState.swift */; };
71008DF02AB907B00036B1F3 /* ObjectAssociation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71008DEF2AB907B00036B1F3 /* ObjectAssociation.swift */; };
710C4FC62C50514D00F7196A /* MenuBarItemSpacingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 710C4FC52C50514D00F7196A /* MenuBarItemSpacingManager.swift */; };
711535F22AB9F6C1003193AD /* BindingExposable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711535F12AB9F6C1003193AD /* BindingExposable.swift */; };
7127A9E92C45662B00D99DEF /* MenuBarSearchPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7127A9E82C45662B00D99DEF /* MenuBarSearchPanel.swift */; };
7127A9F02C4687EE00D99DEF /* VisualEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7127A9EF2C4687EE00D99DEF /* VisualEffectView.swift */; };
Expand Down Expand Up @@ -230,6 +231,7 @@
17FE5AD22BCA7A7500E4F8D9 /* ScreenStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenStateManager.swift; sourceTree = "<group>"; };
17FE5AD42BCA9BE200E4F8D9 /* ScreenState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenState.swift; sourceTree = "<group>"; };
71008DEF2AB907B00036B1F3 /* ObjectAssociation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjectAssociation.swift; sourceTree = "<group>"; };
710C4FC52C50514D00F7196A /* MenuBarItemSpacingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarItemSpacingManager.swift; sourceTree = "<group>"; };
711535F12AB9F6C1003193AD /* BindingExposable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BindingExposable.swift; sourceTree = "<group>"; };
7127A9E82C45662B00D99DEF /* MenuBarSearchPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarSearchPanel.swift; sourceTree = "<group>"; };
7127A9EF2C4687EE00D99DEF /* VisualEffectView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VisualEffectView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -598,6 +600,14 @@
path = ScreenState;
sourceTree = "<group>";
};
710C4FC42C50511000F7196A /* MenuBarItemSpacing */ = {
isa = PBXGroup;
children = (
710C4FC52C50514D00F7196A /* MenuBarItemSpacingManager.swift */,
);
path = MenuBarItemSpacing;
sourceTree = "<group>";
};
7127A9E72C45661C00D99DEF /* MenuBarSearch */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -683,6 +693,7 @@
17C261E72B5AEB7A0076F129 /* Main */,
7166834E2A768190006ABF84 /* MenuBar */,
179F13BC2B91E7F700EC6B52 /* MenuBarAppearance */,
710C4FC42C50511000F7196A /* MenuBarItemSpacing */,
7127A9E72C45661C00D99DEF /* MenuBarSearch */,
71F4280B2C3B5BF00025706C /* Navigation */,
1787C42A2B16ADF2002F50DF /* Permissions */,
Expand Down Expand Up @@ -1008,6 +1019,7 @@
7166834B2A76811C006ABF84 /* Logger+initWithCategory.swift in Sources */,
17B380F52ADCBE090002C9C3 /* LocalEventMonitorModifier.swift in Sources */,
17B331D52B991D8D0084EBB0 /* AdvancedSettingsManager.swift in Sources */,
710C4FC62C50514D00F7196A /* MenuBarItemSpacingManager.swift in Sources */,
170CF8902B101D980073F982 /* ColorStop.swift in Sources */,
1773546C2B1BBBA1001CF731 /* MenuBarShapePicker.swift in Sources */,
17D1AC8F2B97E71D00726180 /* AdvancedSettingsPane.swift in Sources */,
Expand Down
3 changes: 3 additions & 0 deletions Ice/Main/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ final class AppState: ObservableObject {
/// The app's hotkey registry.
nonisolated let hotkeyRegistry = HotkeyRegistry()

/// Manager for menu bar item spacing.
let spacingManager = MenuBarItemSpacingManager()

/// Manager for app updates.
let updatesManager = UpdatesManager()

Expand Down
167 changes: 167 additions & 0 deletions Ice/MenuBarItemSpacing/MenuBarItemSpacingManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
//
// MenuBarItemSpacingManager.swift
// Ice
//

import Cocoa
import Combine
import OSLog

/// Manager for menu bar item spacing.
class MenuBarItemSpacingManager {
/// UserDefaults keys.
private enum Key: String {
case spacing = "NSStatusItemSpacing"
case padding = "NSStatusItemSelectionPadding"

/// The default value for the key.
var defaultValue: Int {
switch self {
case .spacing: 16
case .padding: 16
}
}
}

/// An error that groups multiple failed app relaunches.
private struct GroupedRelaunchError: LocalizedError {
let failedApps: [String]

var errorDescription: String? {
"The following applications failed to relaunch:\n" + failedApps.joined(separator: "\n")
}

var recoverySuggestion: String? {
"You may need to log out for the changes to take effect."
}
}

/// The offset to apply to the default spacing and padding.
/// Does not take effect until ``applyOffset()`` is called.
var offset = 0

/// Runs a command with the given arguments.
private func runCommand(_ command: String, with arguments: [String]) async throws {
let process = Process()

process.executableURL = URL(filePath: "/usr/bin/env")
process.arguments = CollectionOfOne(command) + arguments

let task = Task.detached {
try process.run()
process.waitUntilExit()
}

return try await task.value
}

/// Removes the value for the specified key.
private func removeValue(forKey key: Key) async throws {
try await runCommand("defaults", with: ["-currentHost", "delete", "-globalDomain", key.rawValue])
}

/// Sets the value for the specified key to the key's default value plus the given offset.
private func setOffset(_ offset: Int, forKey key: Key) async throws {
try await runCommand("defaults", with: ["-currentHost", "write", "-globalDomain", key.rawValue, "-int", String(key.defaultValue + offset)])
}

/// Asynchronously quits the given app.
private func quitApp(_ app: NSRunningApplication) async throws {
try await runCommand("kill", with: [String(app.processIdentifier)])
var cancellable: AnyCancellable?
return try await withCheckedThrowingContinuation { continuation in
cancellable = app.publisher(for: \.isTerminated).sink { isTerminated in
if isTerminated {
cancellable?.cancel()
continuation.resume()
}
}
}
}

/// Asynchronously launches the app at the given URL.
private func launchApp(at applicationURL: URL, bundleIdentifier: String) async throws {
if let app = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == bundleIdentifier }) {
Logger.spacing.debug("Application \"\(app.localizedName ?? "<NIL>")\" is already open, so skipping launch")
return
}
let configuration = NSWorkspace.OpenConfiguration()
configuration.activates = false
configuration.addsToRecentItems = false
configuration.createsNewApplicationInstance = false
configuration.promptsUserIfNeeded = false
try await NSWorkspace.shared.openApplication(at: applicationURL, configuration: configuration)
}

/// Asynchronously relaunches the given app.
private func relaunchApp(_ app: NSRunningApplication) async throws {
struct RelaunchError: Error { }
guard
let url = app.bundleURL,
let bundleIdentifier = app.bundleIdentifier
else {
throw RelaunchError()
}
try await quitApp(app)
try? await Task.sleep(for: .milliseconds(50))
try await launchApp(at: url, bundleIdentifier: bundleIdentifier)
}

/// Applies the current ``offset``.
///
/// - Note: Calling this restarts all apps with a menu bar item.
func applyOffset() async throws {
if offset == 0 {
try await removeValue(forKey: .spacing)
try await removeValue(forKey: .padding)
} else {
try await setOffset(offset, forKey: .spacing)
try await setOffset(offset, forKey: .padding)
}

try? await Task.sleep(for: .milliseconds(100))

let items = MenuBarItem.getMenuBarItemsPrivateAPI(onScreenOnly: false, activeSpaceOnly: true)
let pids = Set(items.map { $0.ownerPID })

var failedApps = [String]()

for pid in pids {
guard
let app = NSRunningApplication(processIdentifier: pid),
// ControlCenter handles its own relaunch, so quit it separately
app.bundleIdentifier != "com.apple.controlcenter",
app != .current
else {
continue
}
do {
try await relaunchApp(app)
} catch {
if let name = app.localizedName {
failedApps.append(name)
}
}
}

try? await Task.sleep(for: .milliseconds(100))

if let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.controlcenter").first {
do {
try await quitApp(app)
} catch {
if let name = app.localizedName {
failedApps.append(name)
}
}
}

if !failedApps.isEmpty {
throw GroupedRelaunchError(failedApps: failedApps)
}
}
}

private extension Logger {
static let spacing = Logger(category: "Spacing")
}
12 changes: 12 additions & 0 deletions Ice/Settings/SettingsManagers/GeneralSettingsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ final class GeneralSettingsManager: ObservableObject {
/// menu bar.
@Published var showOnScroll = true

/// The offset to apply to the menu bar item spacing and padding.
@Published var itemSpacingOffset: Double = 0

/// A Boolean value that indicates whether the hidden section
/// should automatically rehide.
@Published var autoRehide = true
Expand Down Expand Up @@ -80,6 +83,7 @@ final class GeneralSettingsManager: ObservableObject {
Defaults.ifPresent(key: .showOnClick, assign: &showOnClick)
Defaults.ifPresent(key: .showOnHover, assign: &showOnHover)
Defaults.ifPresent(key: .showOnScroll, assign: &showOnScroll)
Defaults.ifPresent(key: .itemSpacingOffset, assign: &itemSpacingOffset)
Defaults.ifPresent(key: .autoRehide, assign: &autoRehide)
Defaults.ifPresent(key: .rehideInterval, assign: &rehideInterval)

Expand Down Expand Up @@ -176,6 +180,14 @@ final class GeneralSettingsManager: ObservableObject {
}
.store(in: &c)

$itemSpacingOffset
.receive(on: DispatchQueue.main)
.sink { [weak appState] offset in
Defaults.set(offset, forKey: .itemSpacingOffset)
appState?.spacingManager.offset = Int(offset)
}
.store(in: &c)

$autoRehide
.receive(on: DispatchQueue.main)
.sink { autoRehide in
Expand Down
70 changes: 70 additions & 0 deletions Ice/Settings/SettingsPanes/GeneralSettingsPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@ struct GeneralSettingsPane: View {
appState.settingsManager.generalSettingsManager
}

private var itemSpacingOffset: LocalizedStringKey {
if manager.itemSpacingOffset == -16 {
LocalizedStringKey("none")
} else if manager.itemSpacingOffset == 0 {
LocalizedStringKey("default")
} else if manager.itemSpacingOffset == 16 {
LocalizedStringKey("max")
} else {
LocalizedStringKey(manager.itemSpacingOffset.formatted())
}
}

private var rehideInterval: LocalizedStringKey {
let formatted = manager.rehideInterval.formatted()
return if manager.rehideInterval == 1 {
Expand All @@ -42,6 +54,9 @@ struct GeneralSettingsPane: View {
showOnHover
showOnScroll
}
Section {
spacingOptions
}
Section {
autoRehideOptions
}
Expand Down Expand Up @@ -198,6 +213,61 @@ struct GeneralSettingsPane: View {
}
}

@ViewBuilder
private var spacingOptions: some View {
VStack(alignment: .leading) {
LabeledContent {
CompactSlider(
value: manager.bindings.itemSpacingOffset,
in: -16...16,
step: 2,
handleVisibility: .hovering(width: 1)
) {
Text(itemSpacingOffset)
.textSelection(.disabled)
}
.compactSliderDisabledHapticFeedback(true)
} label: {
HStack {
Text("Menu bar item spacing")

Spacer()

Button("Apply") {
Task {
do {
try await appState.spacingManager.applyOffset()
} catch {
let alert = NSAlert(error: error)
alert.runModal()
}
}
}
.help("Apply the current spacing")

Button("Reset", systemImage: "arrow.counterclockwise.circle.fill") {
manager.itemSpacingOffset = 0
Task {
do {
try await appState.spacingManager.applyOffset()
} catch {
let alert = NSAlert(error: error)
alert.runModal()
}
}
}
.buttonStyle(.borderless)
.labelStyle(.iconOnly)
.help("Reset to the default spacing")
}
}

Text("Applying this setting will relaunch all apps with menu bar items. Some apps may need to be manually relaunched.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}

@ViewBuilder
private var rehideStrategyPicker: some View {
Picker(selection: manager.bindings.rehideStrategy) {
Expand Down
2 changes: 1 addition & 1 deletion Ice/Settings/SettingsWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@ struct SettingsWindow: Scene {
}
.commandsRemoved()
.windowResizability(.contentSize)
.defaultSize(width: 900, height: 615)
.defaultSize(width: 900, height: 625)
}
}
1 change: 1 addition & 0 deletions Ice/Utilities/Defaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ extension Defaults {
case showOnHover = "ShowOnHover"
case showOnHoverDelay = "ShowOnHoverDelay"
case showOnScroll = "ShowOnScroll"
case itemSpacingOffset = "ItemSpacingOffset"
case autoRehide = "AutoRehide"
case rehideStrategy = "RehideStrategy"
case rehideInterval = "RehideInterval"
Expand Down
Loading