From 55fab6e4c90e9ee7818fad9ed19952c2f3301255 Mon Sep 17 00:00:00 2001 From: "waylon.wang" Date: Sat, 13 Sep 2025 19:28:50 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E2=80=8B=E2=80=8BImplement=20order?= =?UTF-8?q?=20options=20for=20multi-display=20mode=20and=20a=20dual-row=20?= =?UTF-8?q?layout=20for=20compact=20mode.=E2=80=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Spaceman/Helpers/IconCreator.swift | 79 ++++++++++- Spaceman/Helpers/SpaceObserver.swift | 66 +++++++-- Spaceman/Helpers/SpaceSwitcher.swift | 7 +- Spaceman/Model/IconWidth.swift | 4 + Spaceman/Model/LayoutMode.swift | 5 + Spaceman/Model/SpaceNameInfo.swift | 2 + Spaceman/View/PreferencesView.swift | 128 ++++++++++++++++-- Spaceman/View/PreferencesWindow.swift | 2 +- Spaceman/View/StatusBar.swift | 6 +- Spaceman/ViewModel/PreferencesViewModel.swift | 28 ++-- 10 files changed, 285 insertions(+), 42 deletions(-) diff --git a/Spaceman/Helpers/IconCreator.swift b/Spaceman/Helpers/IconCreator.swift index 1452203f..43de9c78 100644 --- a/Spaceman/Helpers/IconCreator.swift +++ b/Spaceman/Helpers/IconCreator.swift @@ -13,6 +13,8 @@ class IconCreator { @AppStorage("layoutMode") private var layoutMode = LayoutMode.medium @AppStorage("displayStyle") private var displayStyle = DisplayStyle.numbersAndRects @AppStorage("hideInactiveSpaces") private var hideInactiveSpaces = false + @AppStorage("dualRows") private var dualRows = false + @AppStorage("dualRowsGap") private var dualRowsGap: Int = 1 private let leftMargin = CGFloat(7) /* FIXME determine actual left margin */ private var displayCount = 1 @@ -68,7 +70,11 @@ class IconCreator { } let iconsWithDisplayProperties = getIconsWithDisplayProps(icons: icons, spaces: spaces) - return mergeIcons(iconsWithDisplayProperties) + if dualRows && layoutMode == .compact { + return mergeIconsTwoRows(iconsWithDisplayProperties) + } else { + return mergeIcons(iconsWithDisplayProperties) + } } private func createNumberedIcons(_ spaces: [Space]) -> [NSImage] { @@ -248,6 +254,77 @@ class IconCreator { return image } + private func mergeIconsTwoRows(_ iconsWithDisplayProperties: [(image: NSImage, nextSpaceOnDifferentDisplay: Bool, isFullScreen: Bool)]) -> NSImage { + struct Column { var top: (NSImage, Bool)?; var bottom: (NSImage, Bool)?; var width: CGFloat = 0; var gapAfter: CGFloat = 0 } + var columns: [Column] = [] + var current = Column() + var placeTop = true + + for (idx, icon) in iconsWithDisplayProperties.enumerated() { + if placeTop { + current.top = (icon.image, icon.isFullScreen) + current.width = max(current.width, icon.image.size.width) + placeTop = false + } else { + current.bottom = (icon.image, icon.isFullScreen) + 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) + current = Column() + } + } + + let totalWidth = columns.reduce(CGFloat(0)) { $0 + $1.width + $1.gapAfter } + let gap = CGFloat(max(0, min(dualRowsGap, 3))) + let imageHeight = iconSize.height * 2 + gap + let image = NSImage(size: NSSize(width: totalWidth, height: imageHeight)) + + image.lockFocus() + var left = CGFloat.zero + var currentSpaceNumber = 1 + var currentFullScreenSpaceNumber = 1 + iconWidths = [] + + for col in columns { + if let top = col.top { + top.0.draw(at: NSPoint(x: left, y: iconSize.height + gap), from: .zero, operation: .sourceOver, fraction: 1.0) + let right = left + col.width + col.gapAfter + if top.1 { // isFullScreen + iconWidths.append(IconWidth(left: left + leftMargin, right: right + leftMargin, top: iconSize.height + gap, bottom: imageHeight, index: -currentFullScreenSpaceNumber)) + currentFullScreenSpaceNumber += 1 + } else { + iconWidths.append(IconWidth(left: left + leftMargin, right: right + leftMargin, top: iconSize.height + gap, bottom: imageHeight, index: currentSpaceNumber)) + currentSpaceNumber += 1 + } + } + if let bottom = col.bottom { + bottom.0.draw(at: NSPoint(x: left, y: 0), from: .zero, operation: .sourceOver, fraction: 1.0) + let right = left + col.width + col.gapAfter + if bottom.1 { // isFullScreen + iconWidths.append(IconWidth(left: left + leftMargin, right: right + leftMargin, top: 0, bottom: iconSize.height, index: -currentFullScreenSpaceNumber)) + currentFullScreenSpaceNumber += 1 + } else { + iconWidths.append(IconWidth(left: left + leftMargin, right: right + leftMargin, top: 0, bottom: iconSize.height, index: currentSpaceNumber)) + currentSpaceNumber += 1 + } + } + left += col.width + col.gapAfter + } + image.isTemplate = true + image.unlockFocus() + return image + } + private func getStringAttributes(alpha: CGFloat, fontSize: CGFloat = .zero) -> [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..8d7249f4 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 + // Display ordering preferences + @AppStorage("displaySortPriority") private var displaySortPriority = DisplaySortPriority.horizontal + @AppStorage("displayHorizontalOrder") private var displayHorizontalOrder = HorizontalSortOrder.leftToRight + @AppStorage("displayVerticalOrder") private var displayVerticalOrder = VerticalSortOrder.topToBottom 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.displayHorizontalOrder { + 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.displayVerticalOrder { + case .topToBottom: return a.y > b.y + case .bottomToTop: return a.y < b.y + } + } + switch displaySortPriority { + 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,10 +105,14 @@ 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]() @@ -144,7 +185,8 @@ class SpaceObserver { } spaceNameCache.cache[spaceNumber] = space.spaceName - let nameInfo = SpaceNameInfo(spaceNum: spaceNumber, spaceName: space.spaceName, spaceByDesktopID: spaceByDesktopID) + var nameInfo = SpaceNameInfo(spaceNum: spaceNumber, spaceName: space.spaceName, spaceByDesktopID: spaceByDesktopID) + nameInfo.currentDisplayIndex = currentDisplayIndexByID[displayID] 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/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..39d954ab 100644 --- a/Spaceman/Model/LayoutMode.swift +++ b/Spaceman/Model/LayoutMode.swift @@ -10,3 +10,8 @@ import Foundation enum LayoutMode: Int { case compact, medium, large } + +// Display arrangement preferences (shared by UI and sorting) +enum DisplaySortPriority: Int { case horizontal, vertical } +enum HorizontalSortOrder: Int { case leftToRight, rightToLeft } +enum VerticalSortOrder: Int { case topToBottom, bottomToTop } diff --git a/Spaceman/Model/SpaceNameInfo.swift b/Spaceman/Model/SpaceNameInfo.swift index eaef12d9..906118e6 100644 --- a/Spaceman/Model/SpaceNameInfo.swift +++ b/Spaceman/Model/SpaceNameInfo.swift @@ -11,4 +11,6 @@ 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 } diff --git a/Spaceman/View/PreferencesView.swift b/Spaceman/View/PreferencesView.swift index 2487b757..14287653 100644 --- a/Spaceman/View/PreferencesView.swift +++ b/Spaceman/View/PreferencesView.swift @@ -19,11 +19,18 @@ struct PreferencesView: View { @AppStorage("layoutMode") private var layoutMode = LayoutMode.medium @AppStorage("hideInactiveSpaces") private var hideInactiveSpaces = false @AppStorage("restartNumberingByDesktop") private var restartNumberingByDesktop = false + // Display arrangement preferences (UI only) + @AppStorage("displaySortPriority") private var displaySortPriority = DisplaySortPriority.horizontal + @AppStorage("displayHorizontalOrder") private var displayHorizontalOrder = HorizontalSortOrder.leftToRight + @AppStorage("displayVerticalOrder") private var displayVerticalOrder = VerticalSortOrder.topToBottom @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 + // Dual row settings (UI only) + @AppStorage("dualRows") private var dualRows = false + @AppStorage("dualRowsGap") private var dualRowsGap: Int = 1 @StateObject private var prefsVM = PreferencesViewModel() @@ -115,6 +122,8 @@ struct PreferencesView: View { generalPane Divider() + displaysPane + Divider() spacesPane Divider() switchingPane @@ -157,13 +166,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("Multi-display ordering") + Spacer() + Picker("", selection: $displaySortPriority) { + Text("Horizontal first").tag(DisplaySortPriority.horizontal) + Text("Vertical first").tag(DisplaySortPriority.vertical) + } + .pickerStyle(.segmented) + .controlSize(.small) + .fixedSize() + } + + HStack(spacing: 12) { + Text("Horizontal order") + Spacer() + Picker("", selection: $displayHorizontalOrder) { + Text("Left to right").tag(HorizontalSortOrder.leftToRight) + Text("Right to left").tag(HorizontalSortOrder.rightToLeft) + } + .pickerStyle(.segmented) + .controlSize(.small) + .fixedSize() + } + + HStack(spacing: 12) { + Text("Vertical order") + Spacer() + Picker("", selection: $displayVerticalOrder) { + Text("Top to bottom").tag(VerticalSortOrder.topToBottom) + Text("Bottom to top").tag(VerticalSortOrder.bottomToTop) + } + .pickerStyle(.segmented) + .controlSize(.small) + .fixedSize() + } + } + .padding() + .disabled(!hasMultipleDisplays) + .onChange(of: displaySortPriority) { _ in + NotificationCenter.default.post(name: NSNotification.Name(rawValue: "ButtonPressed"), object: nil) + } + .onChange(of: displayHorizontalOrder) { _ in + NotificationCenter.default.post(name: NSNotification.Name(rawValue: "ButtonPressed"), object: nil) + } + .onChange(of: displayVerticalOrder) { _ 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,16 +247,43 @@ 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("Compact").tag(LayoutMode.compact) + Text("Medium").tag(LayoutMode.medium) + Text("Large").tag(LayoutMode.large) + } + .pickerStyle(.segmented) + + HStack(spacing: 8) { + // Dual row toggle (compact only) + Toggle("Dual row", isOn: $dualRows) + .disabled(layoutMode != .compact) + Spacer() + // Line spacing picker (active only when Dual row is ON in compact) + HStack(spacing: 6) { + Text("Line spacing") + .foregroundColor((layoutMode != .compact || !dualRows) ? .secondary : .primary) + Picker("", selection: $dualRowsGap) { + Text("0").tag(0) + Text("1").tag(1) + Text("2").tag(2) + Text("3").tag(3) + } + .pickerStyle(.segmented) + .controlSize(.small) + .frame(maxWidth: 120) + } + .disabled(layoutMode != .compact || !dualRows) + } } - .pickerStyle(.segmented) .onChange(of: layoutMode) { val in layoutMode = val + if val != .compact { dualRows = false } + // Do not trigger status bar refresh here for dual-row UI; only persist values. NotificationCenter.default.post(name: NSNotification.Name(rawValue: "ButtonPressed"), object: nil) } + // Intentionally not posting ButtonPressed on dual-row changes to keep it UI-only for now } // MARK: - Style Picker @@ -208,31 +306,39 @@ 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.. 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..6efdf621 100644 --- a/Spaceman/View/StatusBar.swift +++ b/Spaceman/View/StatusBar.swift @@ -97,10 +97,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)") diff --git a/Spaceman/ViewModel/PreferencesViewModel.swift b/Spaceman/ViewModel/PreferencesViewModel.swift index 20be0d52..204dfd9a 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() { @@ -40,20 +42,20 @@ class PreferencesViewModel: ObservableObject { let sorted = spaceNamesDict.sorted { (first, second) -> Bool in return first.value.spaceNum < second.value.spaceNum } - 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() { From 36c68c4bacd4c3c0e7a0319c7c231386853fd162 Mon Sep 17 00:00:00 2001 From: "waylon.wang" Date: Sun, 14 Sep 2025 00:01:14 +0800 Subject: [PATCH 2/5] =?UTF-8?q?refactor:=20Integrate=20Dual=20Row=20into?= =?UTF-8?q?=20layout;=20keep=20the=20space=20dropdown=20order=20consistent?= =?UTF-8?q?=20with=20the=20right=E2=80=91click=20menu=20and=20status=20bar?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Spaceman/Helpers/IconCreator.swift | 6 +- Spaceman/Helpers/SpaceObserver.swift | 27 +++---- Spaceman/Model/GuiSize.swift | 1 + Spaceman/Model/LayoutMode.swift | 8 +- Spaceman/Model/SpaceNameInfo.swift | 2 + Spaceman/Utilities/Constants.swift | 13 ++++ Spaceman/View/PreferencesView.swift | 73 ++++++------------- Spaceman/View/PreferencesWindow.swift | 2 +- Spaceman/View/StatusBar.swift | 51 +++++++++---- Spaceman/ViewModel/PreferencesViewModel.swift | 4 +- 10 files changed, 99 insertions(+), 88 deletions(-) diff --git a/Spaceman/Helpers/IconCreator.swift b/Spaceman/Helpers/IconCreator.swift index 43de9c78..5786397b 100644 --- a/Spaceman/Helpers/IconCreator.swift +++ b/Spaceman/Helpers/IconCreator.swift @@ -13,8 +13,6 @@ class IconCreator { @AppStorage("layoutMode") private var layoutMode = LayoutMode.medium @AppStorage("displayStyle") private var displayStyle = DisplayStyle.numbersAndRects @AppStorage("hideInactiveSpaces") private var hideInactiveSpaces = false - @AppStorage("dualRows") private var dualRows = false - @AppStorage("dualRowsGap") private var dualRowsGap: Int = 1 private let leftMargin = CGFloat(7) /* FIXME determine actual left margin */ private var displayCount = 1 @@ -70,7 +68,7 @@ class IconCreator { } let iconsWithDisplayProperties = getIconsWithDisplayProps(icons: icons, spaces: spaces) - if dualRows && layoutMode == .compact { + if layoutMode == .dualRows { return mergeIconsTwoRows(iconsWithDisplayProperties) } else { return mergeIcons(iconsWithDisplayProperties) @@ -285,7 +283,7 @@ class IconCreator { } let totalWidth = columns.reduce(CGFloat(0)) { $0 + $1.width + $1.gapAfter } - let gap = CGFloat(max(0, min(dualRowsGap, 3))) + let gap = CGFloat(sizes.GAP_HEIGHT_DUALROWS) let imageHeight = iconSize.height * 2 + gap let image = NSImage(size: NSSize(width: totalWidth, height: imageHeight)) diff --git a/Spaceman/Helpers/SpaceObserver.swift b/Spaceman/Helpers/SpaceObserver.swift index 8d7249f4..478bf0b8 100644 --- a/Spaceman/Helpers/SpaceObserver.swift +++ b/Spaceman/Helpers/SpaceObserver.swift @@ -11,10 +11,10 @@ import SwiftUI class SpaceObserver { @AppStorage("restartNumberingByDesktop") private var restartNumberingByDesktop = false - // Display ordering preferences - @AppStorage("displaySortPriority") private var displaySortPriority = DisplaySortPriority.horizontal - @AppStorage("displayHorizontalOrder") private var displayHorizontalOrder = HorizontalSortOrder.leftToRight - @AppStorage("displayVerticalOrder") private var displayVerticalOrder = VerticalSortOrder.topToBottom + @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() @@ -48,19 +48,19 @@ class SpaceObserver { let c2 = getDisplayCenter(display: d2) let tol: CGFloat = 2 let cmpX: (CGPoint, CGPoint) -> Bool = { a, b in - switch self.displayHorizontalOrder { + 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.displayVerticalOrder { + switch self.verticalDirection { case .topToBottom: return a.y > b.y case .bottomToTop: return a.y < b.y } } - switch displaySortPriority { + switch displayOrderPriority { case .horizontal: if abs(c1.x - c2.x) > tol { return cmpX(c1, c2) } return cmpY(c1, c2) @@ -118,6 +118,7 @@ class SpaceObserver { 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], @@ -155,14 +156,10 @@ class SpaceObserver { spaceByDesktopID = "F\(lastFullScreenSpaceNumber)" } - while spaceNumber >= spaceNameCache.cache.count { - // Make sure that the name cache is large enough - spaceNameCache.extend() - } - let spaceName = spaceNameCache.cache[spaceNumber] + // Default name for a new/unknown space is "-"; do not reuse other spaces' names var space = Space(displayID: displayID, spaceID: managedSpaceID, - spaceName: spaceName, + spaceName: "-", spaceNumber: spaceNumber, spaceByDesktopID: spaceByDesktopID, isCurrentSpace: isCurrentSpace, @@ -183,10 +180,14 @@ class SpaceObserver { space.spaceName = "FULL" } } + // Optional compatibility: keep cache length in sync (not used for naming anymore) + while spaceNumber >= spaceNameCache.cache.count { spaceNameCache.extend() } spaceNameCache.cache[spaceNumber] = space.spaceName + 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/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/LayoutMode.swift b/Spaceman/Model/LayoutMode.swift index 39d954ab..1532fcf6 100644 --- a/Spaceman/Model/LayoutMode.swift +++ b/Spaceman/Model/LayoutMode.swift @@ -8,10 +8,10 @@ import Foundation enum LayoutMode: Int { - case compact, medium, large + case compact, medium, large, dualRows } // Display arrangement preferences (shared by UI and sorting) -enum DisplaySortPriority: Int { case horizontal, vertical } -enum HorizontalSortOrder: Int { case leftToRight, rightToLeft } -enum VerticalSortOrder: Int { case topToBottom, bottomToTop } +enum DisplayOrderPriority: Int { case horizontal, vertical } +enum HorizontalDirection: Int { case leftToRight, rightToLeft } +enum VerticalDirection: Int { case topToBottom, bottomToTop } diff --git a/Spaceman/Model/SpaceNameInfo.swift b/Spaceman/Model/SpaceNameInfo.swift index 906118e6..bee71377 100644 --- a/Spaceman/Model/SpaceNameInfo.swift +++ b/Spaceman/Model/SpaceNameInfo.swift @@ -13,4 +13,6 @@ struct SpaceNameInfo: Hashable, Codable { 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 14287653..dcf214bf 100644 --- a/Spaceman/View/PreferencesView.swift +++ b/Spaceman/View/PreferencesView.swift @@ -19,18 +19,15 @@ struct PreferencesView: View { @AppStorage("layoutMode") private var layoutMode = LayoutMode.medium @AppStorage("hideInactiveSpaces") private var hideInactiveSpaces = false @AppStorage("restartNumberingByDesktop") private var restartNumberingByDesktop = false - // Display arrangement preferences (UI only) - @AppStorage("displaySortPriority") private var displaySortPriority = DisplaySortPriority.horizontal - @AppStorage("displayHorizontalOrder") private var displayHorizontalOrder = HorizontalSortOrder.leftToRight - @AppStorage("displayVerticalOrder") private var displayVerticalOrder = VerticalSortOrder.topToBottom + @AppStorage("displayOrderPriority") private var displayOrderPriority = DisplayOrderPriority.horizontal + @AppStorage("horizontalDirection") private var horizontalDirection = HorizontalDirection.leftToRight + @AppStorage("verticalDirection") private var verticalDirection = VerticalDirection.topToBottom @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 - // Dual row settings (UI only) - @AppStorage("dualRows") private var dualRows = false - @AppStorage("dualRowsGap") private var dualRowsGap: Int = 1 + // Removed dual row UI; configuration via LayoutMode.dualRows constants @StateObject private var prefsVM = PreferencesViewModel() @@ -186,11 +183,11 @@ struct PreferencesView: View { .fontWeight(.semibold) HStack(spacing: 12) { - Text("Multi-display ordering") + Text("Display order priority") Spacer() - Picker("", selection: $displaySortPriority) { - Text("Horizontal first").tag(DisplaySortPriority.horizontal) - Text("Vertical first").tag(DisplaySortPriority.vertical) + Picker("", selection: $displayOrderPriority) { + Text("Horizontal first").tag(DisplayOrderPriority.horizontal) + Text("Vertical first").tag(DisplayOrderPriority.vertical) } .pickerStyle(.segmented) .controlSize(.small) @@ -198,11 +195,11 @@ struct PreferencesView: View { } HStack(spacing: 12) { - Text("Horizontal order") + Text("Horizontal direction") Spacer() - Picker("", selection: $displayHorizontalOrder) { - Text("Left to right").tag(HorizontalSortOrder.leftToRight) - Text("Right to left").tag(HorizontalSortOrder.rightToLeft) + Picker("", selection: $horizontalDirection) { + Text("Left to right").tag(HorizontalDirection.leftToRight) + Text("Right to left").tag(HorizontalDirection.rightToLeft) } .pickerStyle(.segmented) .controlSize(.small) @@ -210,11 +207,11 @@ struct PreferencesView: View { } HStack(spacing: 12) { - Text("Vertical order") + Text("Vertical direction") Spacer() - Picker("", selection: $displayVerticalOrder) { - Text("Top to bottom").tag(VerticalSortOrder.topToBottom) - Text("Bottom to top").tag(VerticalSortOrder.bottomToTop) + Picker("", selection: $verticalDirection) { + Text("Top to bottom").tag(VerticalDirection.topToBottom) + Text("Bottom to top").tag(VerticalDirection.bottomToTop) } .pickerStyle(.segmented) .controlSize(.small) @@ -223,13 +220,13 @@ struct PreferencesView: View { } .padding() .disabled(!hasMultipleDisplays) - .onChange(of: displaySortPriority) { _ in + .onChange(of: displayOrderPriority) { _ in NotificationCenter.default.post(name: NSNotification.Name(rawValue: "ButtonPressed"), object: nil) } - .onChange(of: displayHorizontalOrder) { _ in + .onChange(of: horizontalDirection) { _ in NotificationCenter.default.post(name: NSNotification.Name(rawValue: "ButtonPressed"), object: nil) } - .onChange(of: displayVerticalOrder) { _ in + .onChange(of: verticalDirection) { _ in NotificationCenter.default.post(name: NSNotification.Name(rawValue: "ButtonPressed"), object: nil) } } @@ -249,41 +246,18 @@ struct PreferencesView: View { private var layoutSizePicker: some View { 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) - - HStack(spacing: 8) { - // Dual row toggle (compact only) - Toggle("Dual row", isOn: $dualRows) - .disabled(layoutMode != .compact) - Spacer() - // Line spacing picker (active only when Dual row is ON in compact) - HStack(spacing: 6) { - Text("Line spacing") - .foregroundColor((layoutMode != .compact || !dualRows) ? .secondary : .primary) - Picker("", selection: $dualRowsGap) { - Text("0").tag(0) - Text("1").tag(1) - Text("2").tag(2) - Text("3").tag(3) - } - .pickerStyle(.segmented) - .controlSize(.small) - .frame(maxWidth: 120) - } - .disabled(layoutMode != .compact || !dualRows) - } + // Dual row and spacing options have been removed; use LayoutMode.dualRows } .onChange(of: layoutMode) { val in layoutMode = val - if val != .compact { dualRows = false } - // Do not trigger status bar refresh here for dual-row UI; only persist values. NotificationCenter.default.post(name: NSNotification.Name(rawValue: "ButtonPressed"), object: nil) } - // Intentionally not posting ButtonPressed on dual-row changes to keep it UI-only for now } // MARK: - Style Picker @@ -310,11 +284,10 @@ struct PreferencesView: View { Picker(selection: $prefsVM.selectedSpace, label: Text("Space")) { ForEach(0.. 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 204dfd9a..ec54bf97 100644 --- a/Spaceman/ViewModel/PreferencesViewModel.swift +++ b/Spaceman/ViewModel/PreferencesViewModel.swift @@ -40,7 +40,9 @@ 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 sortedSpaceNamesDict.isEmpty { From 4ad9a268d14893ed77fea2716472c0128de7442a Mon Sep 17 00:00:00 2001 From: "waylon.wang" Date: Sun, 14 Sep 2025 00:10:17 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20Hide=20=E2=80=9CDisplay=201:?= =?UTF-8?q?=E2=80=9D=20in=20the=20space=20dropdown=20when=20only=20one=20d?= =?UTF-8?q?isplay=20is=20present.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Spaceman/View/PreferencesView.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Spaceman/View/PreferencesView.swift b/Spaceman/View/PreferencesView.swift index dcf214bf..63df7512 100644 --- a/Spaceman/View/PreferencesView.swift +++ b/Spaceman/View/PreferencesView.swift @@ -284,10 +284,12 @@ struct PreferencesView: View { Picker(selection: $prefsVM.selectedSpace, label: Text("Space")) { ForEach(0.. 1 + let label = hasMultipleDisplays ? "Display \(displayIndex): \(spacePart)" : spacePart + Text(label) } } .layoutPriority(3) From bd26693a6a8570f0286f92f036c59b24e3b5f5c4 Mon Sep 17 00:00:00 2001 From: "waylon.wang" Date: Sun, 14 Sep 2025 20:15:02 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20restore=20SpaceNameCache=E2=80=91see?= =?UTF-8?q?ded=20naming=20to=20retain=20space=20names=20across=20ManagedSp?= =?UTF-8?q?aceID=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Spaceman/Helpers/SpaceObserver.swift | 34 ++++++++++++++++------------ 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/Spaceman/Helpers/SpaceObserver.swift b/Spaceman/Helpers/SpaceObserver.swift index 478bf0b8..5be8c24d 100644 --- a/Spaceman/Helpers/SpaceObserver.swift +++ b/Spaceman/Helpers/SpaceObserver.swift @@ -155,33 +155,39 @@ class SpaceObserver { lastFullScreenSpaceNumber += 1 spaceByDesktopID = "F\(lastFullScreenSpaceNumber)" } - - // Default name for a new/unknown space is "-"; do not reuse other spaces' names - var space = Space(displayID: displayID, - spaceID: managedSpaceID, - 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 + } - // Optional compatibility: keep cache length in sync (not used for naming anymore) - while spaceNumber >= spaceNameCache.cache.count { spaceNameCache.extend() } + 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 currentOrder += 1 From 9b285a0c4ff1957ac7619071988208639e65a529 Mon Sep 17 00:00:00 2001 From: "waylon.wang" Date: Sat, 20 Sep 2025 15:19:28 +0800 Subject: [PATCH 5/5] feat: Added configurable fill order for Dual Row layout --- Spaceman/Helpers/IconCreator.swift | 121 +++++++++++++++++--------- Spaceman/Model/LayoutMode.swift | 3 + Spaceman/View/PreferencesView.swift | 17 ++++ Spaceman/View/PreferencesWindow.swift | 2 +- 4 files changed, 103 insertions(+), 40 deletions(-) diff --git a/Spaceman/Helpers/IconCreator.swift b/Spaceman/Helpers/IconCreator.swift index 5786397b..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 @@ -253,35 +254,91 @@ class IconCreator { } private func mergeIconsTwoRows(_ iconsWithDisplayProperties: [(image: NSImage, nextSpaceOnDifferentDisplay: Bool, isFullScreen: Bool)]) -> NSImage { - struct Column { var top: (NSImage, Bool)?; var bottom: (NSImage, Bool)?; var width: CGFloat = 0; var gapAfter: CGFloat = 0 } - var columns: [Column] = [] - var current = Column() - var placeTop = true + // 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 } - for (idx, icon) in iconsWithDisplayProperties.enumerated() { - if placeTop { - current.top = (icon.image, icon.isFullScreen) - current.width = max(current.width, icon.image.size.width) - placeTop = false - } else { - current.bottom = (icon.image, icon.isFullScreen) - current.width = max(current.width, icon.image.size.width) - placeTop = true + // 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) + } } - let isColumnEnd = placeTop || icon.nextSpaceOnDifferentDisplay - if isColumnEnd { - current.gapAfter = icon.nextSpaceOnDifferentDisplay ? displayGapWidth : gapWidth - columns.append(current) - current = Column() - placeTop = true + 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 idx == iconsWithDisplayProperties.count - 1 && (current.top != nil || current.bottom != nil) { - current.gapAfter = 0 - columns.append(current) - current = Column() + 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..