Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 119 additions & 1 deletion Spaceman/Helpers/IconCreator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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] {
Expand Down Expand Up @@ -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..<maxLen {
var col = Column()
if i < top.count {
let t = top[i]
col.top = (t.image, t.isFull, t.tag)
col.width = max(col.width, t.image.size.width)
}
if i < bottom.count {
let b = bottom[i]
col.bottom = (b.image, b.isFull, b.tag)
col.width = max(col.width, b.image.size.width)
}
// Add inter-column gap. After the last column of a display, add display gap (except trailing overall)
let isLastInSegment = (i == maxLen - 1)
col.gapAfter = isLastInSegment ? displayGapWidth : gapWidth
columns.append(col)
}
// Avoid display gap after final segment
if segIdx == segments.count - 1, var last = columns.popLast() {
last.gapAfter = 0
columns.append(last)
}
}
}

// Render
let totalWidth = columns.reduce(CGFloat(0)) { $0 + $1.width + $1.gapAfter }
let gap = CGFloat(sizes.GAP_HEIGHT_DUALROWS)
let imageHeight = iconSize.height * 2 + gap
let image = NSImage(size: NSSize(width: totalWidth, height: imageHeight))

image.lockFocus()
var left = CGFloat.zero
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
iconWidths.append(IconWidth(left: left + leftMargin, right: right + leftMargin, top: iconSize.height + gap, bottom: imageHeight, index: top.2))
}
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
iconWidths.append(IconWidth(left: left + leftMargin, right: right + leftMargin, top: 0, bottom: iconSize.height, index: bottom.2))
}
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()
Expand Down
105 changes: 77 additions & 28 deletions Spaceman/Helpers/SpaceObserver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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() {
Expand All @@ -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],
Expand Down Expand Up @@ -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)
}
Expand Down
7 changes: 5 additions & 2 deletions Spaceman/Helpers/SpaceSwitcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions Spaceman/Model/GuiSize.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
4 changes: 4 additions & 0 deletions Spaceman/Model/IconWidth.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
10 changes: 9 additions & 1 deletion Spaceman/Model/LayoutMode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
4 changes: 4 additions & 0 deletions Spaceman/Model/SpaceNameInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
13 changes: 13 additions & 0 deletions Spaceman/Utilities/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
Loading