diff --git a/Spaceman/Helpers/IconCreator.swift b/Spaceman/Helpers/IconCreator.swift index 1452203f..4cab3dc7 100644 --- a/Spaceman/Helpers/IconCreator.swift +++ b/Spaceman/Helpers/IconCreator.swift @@ -13,6 +13,7 @@ class IconCreator { @AppStorage("layoutMode") private var layoutMode = LayoutMode.medium @AppStorage("displayStyle") private var displayStyle = DisplayStyle.numbersAndRects @AppStorage("hideInactiveSpaces") private var hideInactiveSpaces = false + @AppStorage("dualRowFillOrder") private var dualRowFillOrder = DualRowFillOrder.columnMajor private let leftMargin = CGFloat(7) /* FIXME determine actual left margin */ private var displayCount = 1 @@ -68,7 +69,11 @@ class IconCreator { } let iconsWithDisplayProperties = getIconsWithDisplayProps(icons: icons, spaces: spaces) - return mergeIcons(iconsWithDisplayProperties) + if layoutMode == .dualRows { + return mergeIconsTwoRows(iconsWithDisplayProperties) + } else { + return mergeIcons(iconsWithDisplayProperties) + } } private func createNumberedIcons(_ spaces: [Space]) -> [NSImage] { @@ -248,6 +253,119 @@ class IconCreator { return image } + private func mergeIconsTwoRows(_ iconsWithDisplayProperties: [(image: NSImage, nextSpaceOnDifferentDisplay: Bool, isFullScreen: Bool)]) -> NSImage { + // Column describes a stacked pair (top/bottom) and its rendered width and trailing gap + struct Column { var top: (NSImage, Bool, Int)?; var bottom: (NSImage, Bool, Int)?; var width: CGFloat = 0; var gapAfter: CGFloat = 0 } + + // Pre-compute the target index for each icon: positive for numbered spaces; negative for fullscreen pseudo indices + var assignedIndices: [Int] = [] + var numbered = 1 + var fullscreen = 1 + for i in iconsWithDisplayProperties { + if i.isFullScreen { assignedIndices.append(-fullscreen); fullscreen += 1 } + else { assignedIndices.append(numbered); numbered += 1 } + } + + // Build columns depending on fill order preference + var columns: [Column] = [] + switch dualRowFillOrder { + case .columnMajor: + // Original behavior: fill top then bottom per column + var current = Column() + var placeTop = true + for (idx, icon) in iconsWithDisplayProperties.enumerated() { + let tag = assignedIndices[idx] + if placeTop { + current.top = (icon.image, icon.isFullScreen, tag) + current.width = max(current.width, icon.image.size.width) + placeTop = false + } else { + current.bottom = (icon.image, icon.isFullScreen, tag) + current.width = max(current.width, icon.image.size.width) + placeTop = true + } + let isColumnEnd = placeTop || icon.nextSpaceOnDifferentDisplay + if isColumnEnd { + current.gapAfter = icon.nextSpaceOnDifferentDisplay ? displayGapWidth : gapWidth + columns.append(current) + current = Column() + placeTop = true + } + if idx == iconsWithDisplayProperties.count - 1 && (current.top != nil || current.bottom != nil) { + current.gapAfter = 0 + columns.append(current) + } + } + case .rowMajor: + // New behavior: fill entire top row left-to-right, then bottom row + // First, segment by display to place display gaps correctly + var segments: [[(image: NSImage, nextDisplay: Bool, isFull: Bool, tag: Int)]] = [] + var cur: [(NSImage, Bool, Bool, Int)] = [] + for (idx, icon) in iconsWithDisplayProperties.enumerated() { + cur.append((icon.image, icon.nextSpaceOnDifferentDisplay, icon.isFullScreen, assignedIndices[idx])) + if icon.nextSpaceOnDifferentDisplay { segments.append(cur); cur = [] } + } + if !cur.isEmpty { segments.append(cur) } + + for (segIdx, seg) in segments.enumerated() { + let n = seg.count + let topCount = Int(ceil(Double(n) / 2.0)) + let top = Array(seg.prefix(topCount)) + let bottom = Array(seg.dropFirst(topCount)) + let maxLen = max(top.count, bottom.count) + for i in 0.. [NSAttributedString.Key : Any] { let actualFontSize = fontSize == .zero ? CGFloat(sizes.FONT_SIZE) : fontSize let paragraphStyle = NSMutableParagraphStyle() diff --git a/Spaceman/Helpers/SpaceObserver.swift b/Spaceman/Helpers/SpaceObserver.swift index 319f2c8f..5be8c24d 100644 --- a/Spaceman/Helpers/SpaceObserver.swift +++ b/Spaceman/Helpers/SpaceObserver.swift @@ -11,6 +11,10 @@ import SwiftUI class SpaceObserver { @AppStorage("restartNumberingByDesktop") private var restartNumberingByDesktop = false + @AppStorage("displayOrderPriority") private var displayOrderPriority = DisplayOrderPriority.horizontal + @AppStorage("horizontalDirection") private var horizontalDirection = HorizontalDirection.leftToRight + @AppStorage("verticalDirection") private var verticalDirection = VerticalDirection.topToBottom + @AppStorage("layoutMode") private var layoutMode = LayoutMode.medium private let workspace = NSWorkspace.shared private let conn = _CGSDefaultConnection() @@ -37,16 +41,49 @@ class SpaceObserver { let d2Center = getDisplayCenter(display: display2) return d1Center.x < d2Center.x } + + // Compare two displays according to user preferences + func compareDisplays(d1: NSDictionary, d2: NSDictionary) -> Bool { + let c1 = getDisplayCenter(display: d1) + let c2 = getDisplayCenter(display: d2) + let tol: CGFloat = 2 + let cmpX: (CGPoint, CGPoint) -> Bool = { a, b in + switch self.horizontalDirection { + case .leftToRight: return a.x < b.x + case .rightToLeft: return a.x > b.x + } + } + let cmpY: (CGPoint, CGPoint) -> Bool = { a, b in + // macOS global coordinates origin at bottom-left; larger y is higher + switch self.verticalDirection { + case .topToBottom: return a.y > b.y + case .bottomToTop: return a.y < b.y + } + } + switch displayOrderPriority { + case .horizontal: + if abs(c1.x - c2.x) > tol { return cmpX(c1, c2) } + return cmpY(c1, c2) + case .vertical: + if abs(c1.y - c2.y) > tol { return cmpY(c1, c2) } + return cmpX(c1, c2) + } + } func getDisplayCenter(display: NSDictionary) -> CGPoint { - guard let uuidString = display["Display Identifier"] as? String - else { - return CGPoint(x: 0, y: 0) - } + guard let uuidString = display["Display Identifier"] as? String else { return .zero } let uuid = CFUUIDCreateFromString(kCFAllocatorDefault, uuidString as CFString) - let dId = CGDisplayGetDisplayIDFromUUID(uuid) - let bounds = CGDisplayBounds(dId); - return CGPoint(x: bounds.origin.x + bounds.size.width/2, y: bounds.origin.y + bounds.size.height/2) + let did = CGDisplayGetDisplayIDFromUUID(uuid) + // Prefer NSScreen frame for consistent origin handling + for screen in NSScreen.screens { + if let num = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber, + CGDirectDisplayID(num.uint32Value) == did { + let f = screen.frame + return CGPoint(x: f.origin.x + f.size.width/2, y: f.origin.y + f.size.height/2) + } + } + let b = CGDisplayBounds(did) + return CGPoint(x: b.origin.x + b.size.width/2, y: b.origin.y + b.size.height/2) } @objc public func updateSpaceInformation() { @@ -68,15 +105,20 @@ class SpaceObserver { } } - // sort displays based on location - displays.sort(by: { - display1IsLeft(display1: $0, display2: $1) - }) + // Sort displays based on user preference + displays.sort { a, b in compareDisplays(d1: a, d2: b) } + + // Map sorted display to index (1..D) + var currentDisplayIndexByID: [String: Int] = [:] + for (idx, d) in displays.enumerated() { + if let displayID = d["Display Identifier"] as? String { currentDisplayIndexByID[displayID] = idx + 1 } + } var activeSpaceID = -1 var allSpaces = [Space]() var updatedDict = [String: SpaceNameInfo]() var lastSpaceByDesktopNumber = 0 + var currentOrder = 0 for d in displays { guard let currentSpaces = d["Current Space"] as? [String: Any], @@ -113,38 +155,45 @@ class SpaceObserver { lastFullScreenSpaceNumber += 1 spaceByDesktopID = "F\(lastFullScreenSpaceNumber)" } - - while spaceNumber >= spaceNameCache.cache.count { - // Make sure that the name cache is large enough - spaceNameCache.extend() - } - let spaceName = spaceNameCache.cache[spaceNumber] - var space = Space(displayID: displayID, - spaceID: managedSpaceID, - spaceName: spaceName, - spaceNumber: spaceNumber, - spaceByDesktopID: spaceByDesktopID, - isCurrentSpace: isCurrentSpace, - isFullScreen: isFullScreen) + // 2aa1db4 logic: seed name from SpaceNameCache, then override with saved mapping/fullscreen + while spaceNumber >= spaceNameCache.cache.count { spaceNameCache.extend() } + var seededName = spaceNameCache.cache[spaceNumber] if let data = defaults.data(forKey: "spaceNames"), let dict = try? PropertyListDecoder().decode([String: SpaceNameInfo].self, from: data), let saved = dict[managedSpaceID] { - space.spaceName = saved.spaceName + seededName = saved.spaceName + } else if isFullScreen { if let pid = s["pid"] as? pid_t, let app = NSRunningApplication(processIdentifier: pid), let name = app.localizedName { - space.spaceName = name.prefix(4).uppercased() + seededName = name.prefix(4).uppercased() + } else { - space.spaceName = "FULL" + seededName = "FULL" + } + } else { + // Fall back to cache seed (could be '-') when no saved mapping and not fullscreen + } + var space = Space(displayID: displayID, + spaceID: managedSpaceID, + spaceName: seededName, + spaceNumber: spaceNumber, + spaceByDesktopID: spaceByDesktopID, + isCurrentSpace: isCurrentSpace, + isFullScreen: isFullScreen) + // Write back to cache spaceNameCache.cache[spaceNumber] = space.spaceName - let nameInfo = SpaceNameInfo(spaceNum: spaceNumber, spaceName: space.spaceName, spaceByDesktopID: spaceByDesktopID) + currentOrder += 1 + var nameInfo = SpaceNameInfo(spaceNum: spaceNumber, spaceName: space.spaceName, spaceByDesktopID: spaceByDesktopID) + nameInfo.currentDisplayIndex = currentDisplayIndexByID[displayID] + nameInfo.currentOrder = currentOrder updatedDict[managedSpaceID] = nameInfo allSpaces.append(space) } diff --git a/Spaceman/Helpers/SpaceSwitcher.swift b/Spaceman/Helpers/SpaceSwitcher.swift index 1a22ebd3..f1c523ea 100644 --- a/Spaceman/Helpers/SpaceSwitcher.swift +++ b/Spaceman/Helpers/SpaceSwitcher.swift @@ -43,10 +43,13 @@ class SpaceSwitcher { } } - public func switchUsingLocation(iconWidths: [IconWidth], horizontal: CGFloat, onError: () -> Void) { + public func switchUsingLocation(iconWidths: [IconWidth], point: CGPoint, onError: () -> Void) { var index: Int = 0 for i in 0 ..< iconWidths.count { - if horizontal >= iconWidths[i].left && horizontal < iconWidths[i].right { + let hitX = point.x >= iconWidths[i].left && point.x < iconWidths[i].right + let hasY = iconWidths[i].top != 0 || iconWidths[i].bottom != 0 + let hitY = hasY ? (point.y >= iconWidths[i].top && point.y < iconWidths[i].bottom) : true + if hitX && hitY { index = iconWidths[i].index break } diff --git a/Spaceman/Model/GuiSize.swift b/Spaceman/Model/GuiSize.swift index fcbee19d..720df375 100644 --- a/Spaceman/Model/GuiSize.swift +++ b/Spaceman/Model/GuiSize.swift @@ -10,6 +10,7 @@ import Foundation struct GuiSize { var GAP_WIDTH_SPACES: Int! var GAP_WIDTH_DISPLAYS: Int! + var GAP_HEIGHT_DUALROWS: Int! var ICON_WIDTH_SMALL: Int! var ICON_WIDTH_LARGE: Int! var ICON_WIDTH_XLARGE: Int! diff --git a/Spaceman/Model/IconWidth.swift b/Spaceman/Model/IconWidth.swift index e7162d90..42227953 100644 --- a/Spaceman/Model/IconWidth.swift +++ b/Spaceman/Model/IconWidth.swift @@ -10,5 +10,9 @@ import Foundation struct IconWidth: Codable { let left: CGFloat let right: CGFloat + // For dual-row layout, use top/bottom to enable vertical hit testing (0 means single row) + var top: CGFloat = 0 + var bottom: CGFloat = 0 + // Positive: space number; Negative: full-screen pseudo index (-1, -2) let index: Int } diff --git a/Spaceman/Model/LayoutMode.swift b/Spaceman/Model/LayoutMode.swift index 7304fef6..d2d14838 100644 --- a/Spaceman/Model/LayoutMode.swift +++ b/Spaceman/Model/LayoutMode.swift @@ -8,5 +8,13 @@ import Foundation enum LayoutMode: Int { - case compact, medium, large + case compact, medium, large, dualRows } + +// Display arrangement preferences (shared by UI and sorting) +enum DisplayOrderPriority: Int { case horizontal, vertical } +enum HorizontalDirection: Int { case leftToRight, rightToLeft } +enum VerticalDirection: Int { case topToBottom, bottomToTop } + +// Dual-row fill order (visual ordering of spaces in dual-row layout) +enum DualRowFillOrder: Int { case columnMajor, rowMajor } diff --git a/Spaceman/Model/SpaceNameInfo.swift b/Spaceman/Model/SpaceNameInfo.swift index eaef12d9..bee71377 100644 --- a/Spaceman/Model/SpaceNameInfo.swift +++ b/Spaceman/Model/SpaceNameInfo.swift @@ -11,4 +11,8 @@ struct SpaceNameInfo: Hashable, Codable { let spaceNum: Int let spaceName: String let spaceByDesktopID: String + // Current display index after applying user display ordering (1..D). Optional for backward compatibility. + var currentDisplayIndex: Int? = nil + // Current global order in status bar/menu (1..N) after applying display sorting. + var currentOrder: Int? = nil } diff --git a/Spaceman/Utilities/Constants.swift b/Spaceman/Utilities/Constants.swift index 4d27c49b..6731d288 100644 --- a/Spaceman/Utilities/Constants.swift +++ b/Spaceman/Utilities/Constants.swift @@ -22,9 +22,20 @@ struct Constants { // 7.5 = 90 px ; void left static let sizes: [LayoutMode: GuiSize] = [ + .dualRows: GuiSize( + GAP_WIDTH_SPACES: 3, + GAP_WIDTH_DISPLAYS: 8, + GAP_HEIGHT_DUALROWS: 3, + ICON_WIDTH_SMALL: 16, + ICON_WIDTH_LARGE: 24, + ICON_WIDTH_XLARGE: 36, + ICON_HEIGHT: 10, + FONT_SIZE: 9 + ), .compact: GuiSize( GAP_WIDTH_SPACES: 3, GAP_WIDTH_DISPLAYS: 8, + GAP_HEIGHT_DUALROWS: 0, ICON_WIDTH_SMALL: 16, ICON_WIDTH_LARGE: 24, ICON_WIDTH_XLARGE: 36, @@ -34,6 +45,7 @@ struct Constants { .medium: GuiSize( GAP_WIDTH_SPACES: 5, GAP_WIDTH_DISPLAYS: 12, + GAP_HEIGHT_DUALROWS: 0, ICON_WIDTH_SMALL: 18, ICON_WIDTH_LARGE: 32, ICON_WIDTH_XLARGE: 42, @@ -43,6 +55,7 @@ struct Constants { .large: GuiSize( GAP_WIDTH_SPACES: 5, GAP_WIDTH_DISPLAYS: 14, + GAP_HEIGHT_DUALROWS: 0, ICON_WIDTH_SMALL: 20, ICON_WIDTH_LARGE: 34, ICON_WIDTH_XLARGE: 49, diff --git a/Spaceman/View/PreferencesView.swift b/Spaceman/View/PreferencesView.swift index 2487b757..5b29841d 100644 --- a/Spaceman/View/PreferencesView.swift +++ b/Spaceman/View/PreferencesView.swift @@ -19,11 +19,16 @@ struct PreferencesView: View { @AppStorage("layoutMode") private var layoutMode = LayoutMode.medium @AppStorage("hideInactiveSpaces") private var hideInactiveSpaces = false @AppStorage("restartNumberingByDesktop") private var restartNumberingByDesktop = false + @AppStorage("displayOrderPriority") private var displayOrderPriority = DisplayOrderPriority.horizontal + @AppStorage("horizontalDirection") private var horizontalDirection = HorizontalDirection.leftToRight + @AppStorage("verticalDirection") private var verticalDirection = VerticalDirection.topToBottom + @AppStorage("dualRowFillOrder") private var dualRowFillOrder = DualRowFillOrder.columnMajor @AppStorage("schema") private var keySet = KeySet.toprow @AppStorage("withShift") private var withShift = false @AppStorage("withControl") private var withControl = false @AppStorage("withOption") private var withOption = false @AppStorage("withCommand") private var withCommand = false + // Removed dual row UI; configuration via LayoutMode.dualRows constants @StateObject private var prefsVM = PreferencesViewModel() @@ -115,6 +120,8 @@ struct PreferencesView: View { generalPane Divider() + displaysPane + Divider() spacesPane Divider() switchingPane @@ -132,6 +139,19 @@ struct PreferencesView: View { Toggle("Refresh spaces in background", isOn: $autoRefreshSpaces) shortcutRecorder.disabled(autoRefreshSpaces) layoutSizePicker + HStack(spacing: 12) { + Text("Fill order") + .foregroundColor(layoutMode == .dualRows ? .primary : .secondary) + Spacer() + Picker("", selection: $dualRowFillOrder) { + Text("Row first").tag(DualRowFillOrder.rowMajor) + Text("Column first").tag(DualRowFillOrder.columnMajor) + } + .pickerStyle(.segmented) + .controlSize(.small) + .fixedSize() + } + .disabled(layoutMode != .dualRows) } .padding() .onChange(of: autoRefreshSpaces) { enabled in @@ -144,6 +164,9 @@ struct PreferencesView: View { KeyboardShortcuts.enable(.refresh) } } + .onChange(of: dualRowFillOrder) { _ in + NotificationCenter.default.post(name: NSNotification.Name(rawValue: "ButtonPressed"), object: nil) + } } // MARK: - Spaces pane @@ -157,13 +180,75 @@ struct PreferencesView: View { Toggle("Only show active spaces", isOn: $hideInactiveSpaces) .disabled(displayStyle == .rects) - Toggle("Restart space numbering by desktop", isOn: $restartNumberingByDesktop) + Toggle("Restart space numbering by display", isOn: $restartNumberingByDesktop) } .padding() .onChange(of: hideInactiveSpaces) { _ in NotificationCenter.default.post(name: NSNotification.Name(rawValue: "ButtonPressed"), object: nil) } + .onChange(of: restartNumberingByDesktop) { _ in + NotificationCenter.default.post(name: NSNotification.Name(rawValue: "ButtonPressed"), object: nil) + } } + + // MARK: - Displays pane (UI only; disabled when only one display) + private var displaysPane: some View { + let hasMultipleDisplays = NSScreen.screens.count > 1 + return VStack(alignment: .leading, spacing: 12) { + Text("Displays") + .font(.title2) + .fontWeight(.semibold) + + HStack(spacing: 12) { + Text("Display order priority") + Spacer() + Picker("", selection: $displayOrderPriority) { + Text("Horizontal first").tag(DisplayOrderPriority.horizontal) + Text("Vertical first").tag(DisplayOrderPriority.vertical) + } + .pickerStyle(.segmented) + .controlSize(.small) + .fixedSize() + } + + HStack(spacing: 12) { + Text("Horizontal direction") + Spacer() + Picker("", selection: $horizontalDirection) { + Text("Left to right").tag(HorizontalDirection.leftToRight) + Text("Right to left").tag(HorizontalDirection.rightToLeft) + } + .pickerStyle(.segmented) + .controlSize(.small) + .fixedSize() + } + + HStack(spacing: 12) { + Text("Vertical direction") + Spacer() + Picker("", selection: $verticalDirection) { + Text("Top to bottom").tag(VerticalDirection.topToBottom) + Text("Bottom to top").tag(VerticalDirection.bottomToTop) + } + .pickerStyle(.segmented) + .controlSize(.small) + .fixedSize() + } + } + .padding() + .disabled(!hasMultipleDisplays) + .onChange(of: displayOrderPriority) { _ in + NotificationCenter.default.post(name: NSNotification.Name(rawValue: "ButtonPressed"), object: nil) + } + .onChange(of: horizontalDirection) { _ in + NotificationCenter.default.post(name: NSNotification.Name(rawValue: "ButtonPressed"), object: nil) + } + .onChange(of: verticalDirection) { _ in + NotificationCenter.default.post(name: NSNotification.Name(rawValue: "ButtonPressed"), object: nil) + } + } + + // (Displays pane removed in this revert) // MARK: - Shortcut Recorder private var shortcutRecorder: some View { @@ -176,12 +261,16 @@ struct PreferencesView: View { // MARK: - Layout Size Picker private var layoutSizePicker: some View { - Picker(selection: $layoutMode, label: Text("Layout size")) { - Text("Compact").tag(LayoutMode.compact) - Text("Medium").tag(LayoutMode.medium) - Text("Large").tag(LayoutMode.large) + VStack(alignment: .leading, spacing: 8) { + Picker(selection: $layoutMode, label: Text("Layout size")) { + Text("Dual Row").tag(LayoutMode.dualRows) + Text("Compact").tag(LayoutMode.compact) + Text("Medium").tag(LayoutMode.medium) + Text("Large").tag(LayoutMode.large) + } + .pickerStyle(.segmented) + // Dual row and spacing options have been removed; use LayoutMode.dualRows } - .pickerStyle(.segmented) .onChange(of: layoutMode) { val in layoutMode = val NotificationCenter.default.post(name: NSNotification.Name(rawValue: "ButtonPressed"), object: nil) @@ -208,31 +297,40 @@ struct PreferencesView: View { // MARK: - Space Name Editor private var spaceNameEditor: some View { - HStack { + HStack(alignment: .center, spacing: 12) { Picker(selection: $prefsVM.selectedSpace, label: Text("Space")) { ForEach(0.. 1 + let label = hasMultipleDisplays ? "Display \(displayIndex): \(spacePart)" : spacePart + Text(label) } } + .layoutPriority(3) .onChange(of: prefsVM.selectedSpace) { val in if (prefsVM.sortedSpaceNamesDict.count > val) { + prefsVM.selectedKey = prefsVM.sortedSpaceNamesDict[val].key prefsVM.spaceName = prefsVM.sortedSpaceNamesDict[val].value.spaceName } else { prefsVM.spaceName = "-" + prefsVM.selectedKey = "" } } - + TextField( "Name (max 4 char.)", text: Binding( - get: {prefsVM.spaceName}, + get: { prefsVM.spaceName }, set: { prefsVM.spaceName = $0.prefix(4).trimmingCharacters(in: .whitespacesAndNewlines) updateName() } - ) ) + .frame(width: 90) } } diff --git a/Spaceman/View/PreferencesWindow.swift b/Spaceman/View/PreferencesWindow.swift index e5e7455a..1f023a8f 100644 --- a/Spaceman/View/PreferencesWindow.swift +++ b/Spaceman/View/PreferencesWindow.swift @@ -11,7 +11,7 @@ import AppKit class PreferencesWindow: NSWindow { init() { super.init( - contentRect: NSRect(x: 0, y: 0, width: 400, height: 330), + contentRect: NSRect(x: 0, y: 0, width: 400, height: 710), styleMask: [.titled, .fullSizeContentView], backing: .buffered, defer: false diff --git a/Spaceman/View/StatusBar.swift b/Spaceman/View/StatusBar.swift index 8ec590ab..c749ec00 100644 --- a/Spaceman/View/StatusBar.swift +++ b/Spaceman/View/StatusBar.swift @@ -15,6 +15,9 @@ class StatusBar: NSObject, NSMenuDelegate { private var statusBarItem: NSStatusItem! private var statusBarMenu: NSMenu! + private var updatesItem: NSMenuItem! + private var prefItem: NSMenuItem! + private var quitItem: NSMenuItem! private var prefsWindow: PreferencesWindow! private var spaceSwitcher: SpaceSwitcher! private var shortcutHelper: ShortcutHelper! @@ -44,19 +47,19 @@ class StatusBar: NSObject, NSMenuDelegate { view.frame = NSRect(x: 0, y: 0, width: 220, height: 70) about.view = view - let updates = NSMenuItem( + updatesItem = NSMenuItem( title: "Check for updates...", action: #selector(updaterController.checkForUpdates(_:)), keyEquivalent: "") - updates.target = updaterController + updatesItem.target = updaterController - let pref = NSMenuItem( + prefItem = NSMenuItem( title: "Preferences...", action: #selector(showPreferencesWindow(_:)), keyEquivalent: "") - pref.target = self + prefItem.target = self - let quit = NSMenuItem( + quitItem = NSMenuItem( title: "Quit Spaceman", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "") @@ -64,9 +67,9 @@ class StatusBar: NSObject, NSMenuDelegate { statusBarMenu.addItem(about) statusBarMenu.addItem(NSMenuItem.separator()) statusBarMenu.addItem(NSMenuItem.separator()) - statusBarMenu.addItem(updates) - statusBarMenu.addItem(pref) - statusBarMenu.addItem(quit) + statusBarMenu.addItem(updatesItem) + statusBarMenu.addItem(prefItem) + statusBarMenu.addItem(quitItem) //statusBarItem.menu = statusBarMenu statusBarItem.button?.action = #selector(handleClick) @@ -97,10 +100,12 @@ class StatusBar: NSObject, NSMenuDelegate { print("Not switching: just one space visible") return } - let locationInButton = sbButton.convert(event.locationInWindow, from: sbButton) + let locationInButton = sbButton.convert(event.locationInWindow, from: nil) + // Convert to bottom-origin coordinates for hit testing + let adjPoint = NSPoint(x: locationInButton.x, y: sbButton.bounds.height - locationInButton.y) self.spaceSwitcher.switchUsingLocation( iconWidths: self.iconCreator.iconWidths, - horizontal: locationInButton.x, + point: adjPoint, onError: self.flashStatusBar) } else { print("Other event: \(event.type)") @@ -133,15 +138,33 @@ class StatusBar: NSObject, NSMenuDelegate { guard spaces.count > 0 else { return } - var removeCandidateItem = statusBarMenu.items[2] - while (!removeCandidateItem.isSeparatorItem) { - statusBarMenu.removeItem(removeCandidateItem) - removeCandidateItem = statusBarMenu.items[2] + // Remove previously inserted dynamic items between the fixed header and the updates item + let updatesIdx = statusBarMenu.index(of: updatesItem) + if updatesIdx > 2 { + for _ in 2.. 2 && !statusBarMenu.items[2].isSeparatorItem { + statusBarMenu.removeItem(at: 2) + } + } + // Build items grouped by display with a separator between displays + var itemsToInsert: [NSMenuItem] = [] + var lastDisplayID: String? = nil + for space in spaces { + if let last = lastDisplayID, last != space.displayID { + itemsToInsert.append(NSMenuItem.separator()) + } + itemsToInsert.append(makeSwitchToSpaceItem(space: space)) + lastDisplayID = space.displayID } - for space in spaces.reversed() { - let switchItem = makeSwitchToSpaceItem(space: space) - statusBarMenu.insertItem(switchItem, at: 2) + // Ensure there is a separator between the last space item and the updates item + if !itemsToInsert.isEmpty { + itemsToInsert.append(NSMenuItem.separator()) } + // Insert in order at index 2 + var insertIndex = 2 + for item in itemsToInsert { statusBarMenu.insertItem(item, at: insertIndex); insertIndex += 1 } } @objc func showPreferencesWindow(_ sender: AnyObject) { diff --git a/Spaceman/ViewModel/PreferencesViewModel.swift b/Spaceman/ViewModel/PreferencesViewModel.swift index 20be0d52..ec54bf97 100644 --- a/Spaceman/ViewModel/PreferencesViewModel.swift +++ b/Spaceman/ViewModel/PreferencesViewModel.swift @@ -11,10 +11,12 @@ import SwiftUI class PreferencesViewModel: ObservableObject { @AppStorage("autoRefreshSpaces") private var autoRefreshSpaces = false @Published var selectedSpace = 0 + // Preserve selection across reordering by using the managed space ID as a stable key + @Published var selectedKey: String = "" @Published var spaceName = "" @Published var spaceByDesktopID = "" var spaceNamesDict: [String: SpaceNameInfo]! - var sortedSpaceNamesDict: [Dictionary.Element]! + @Published var sortedSpaceNamesDict: [Dictionary.Element]! var timer: Timer! init() { @@ -38,22 +40,24 @@ class PreferencesViewModel: ObservableObject { } let sorted = spaceNamesDict.sorted { (first, second) -> Bool in - return first.value.spaceNum < second.value.spaceNum + let a = first.value.currentOrder ?? first.value.spaceNum + let b = second.value.currentOrder ?? second.value.spaceNum + return a < b } - sortedSpaceNamesDict = sorted - if (selectedSpace < 0 || selectedSpace >= sortedSpaceNamesDict.count) { - selectedSpace = 0 - if (sortedSpaceNamesDict.count < 1) { - sortedSpaceNamesDict.append( - (key: "0", - value: SpaceNameInfo(spaceNum: 0, spaceName: "DISP", spaceByDesktopID: "1") - ) - ) - } - spaceName = sortedSpaceNamesDict[selectedSpace].value.spaceName - spaceByDesktopID = sortedSpaceNamesDict[selectedSpace].value.spaceByDesktopID + if sortedSpaceNamesDict.isEmpty { + sortedSpaceNamesDict.append((key: "0", value: SpaceNameInfo(spaceNum: 0, spaceName: "DISP", spaceByDesktopID: "1"))) + } + // Restore selection by key if available; otherwise clamp index + if !selectedKey.isEmpty, let idx = sortedSpaceNamesDict.firstIndex(where: { $0.key == selectedKey }) { + selectedSpace = idx + } else { + if selectedSpace < 0 || selectedSpace >= sortedSpaceNamesDict.count { selectedSpace = 0 } + selectedKey = sortedSpaceNamesDict[selectedSpace].key } + // Sync fields to the current selected item + spaceName = sortedSpaceNamesDict[selectedSpace].value.spaceName + spaceByDesktopID = sortedSpaceNamesDict[selectedSpace].value.spaceByDesktopID } func updateSpace() {