Skip to content

Commit e1e3633

Browse files
louis.pontoiselwouis
authored andcommitted
feat: drag-and-drop files on the ui (closes #74)
1 parent 7eb216d commit e1e3633

6 files changed

Lines changed: 187 additions & 133 deletions

File tree

alt-tab-macos.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
D04BAD5A6B2F9EEE6FD4185F /* CollectionViewItemTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA4BABBA0312E0EDBA647 /* CollectionViewItemTitle.swift */; };
5454
D04BAD8346A6A32C9749E0B3 /* TabViewItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA293C53EC5CE00D11E02 /* TabViewItem.swift */; };
5555
D04BAE369A14C3126A1606FE /* HelperExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA8F1AA48A323EE5638DC /* HelperExtensions.swift */; };
56+
D04BAE4CE37C303DDD0347B8 /* CollectionViewItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAE073DD0B0D65CD4CBB6 /* CollectionViewItemView.swift */; };
5657
D04BAEAB8AB048FF2B16B131 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BAE5FA03065C5D23C0C2C /* Localizable.strings */; };
5758
D04BAEF78503D7A2CEFB9E9E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAA44C837F3A67403B9DB /* main.swift */; };
5859
D04BAF3B6F75E50E9AA3E1D2 /* LabelAndControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA3D65E7CA78D699EDAB0 /* LabelAndControl.swift */; };
@@ -133,6 +134,7 @@
133134
D04BADBAFB42AE72DBE1E59E /* es */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = es; path = InfoPlist.strings; sourceTree = "<group>"; };
134135
D04BADBCA16C1D448D34F473 /* en */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = en; path = InfoPlist.strings; sourceTree = "<group>"; };
135136
D04BADCB1C0F50340A6CAFC2 /* Preferences.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; };
137+
D04BAE073DD0B0D65CD4CBB6 /* CollectionViewItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CollectionViewItemView.swift; sourceTree = "<group>"; };
136138
D04BAE1243C9B4BE3ED1B524 /* 7 windows - 2 lines - extra wide window.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "7 windows - 2 lines - extra wide window.jpg"; sourceTree = "<group>"; };
137139
D04BAE23C37E0F3B07EEE7B1 /* AboutTab.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AboutTab.swift; sourceTree = "<group>"; };
138140
D04BAE80772D25834E440975 /* Window.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Window.swift; sourceTree = "<group>"; };
@@ -442,6 +444,7 @@
442444
D04BAF40D5E54AD1044B3FF7 /* ThumbnailsPanel.swift */,
443445
D04BA4BABBA0312E0EDBA647 /* CollectionViewItemTitle.swift */,
444446
D04BAC0416F29ADE7BC5A544 /* CollectionViewItemFontIcon.swift */,
447+
D04BAE073DD0B0D65CD4CBB6 /* CollectionViewItemView.swift */,
445448
);
446449
path = "main-window";
447450
sourceTree = "<group>";
@@ -558,6 +561,7 @@
558561
D04BA6D9DA2A8BCD93347F0E /* CollectionViewItemFontIcon.swift in Sources */,
559562
D04BA9EE5D34A2789DCB0EE2 /* Sysctl.swift in Sources */,
560563
D04BAC4F69FE9563BC1C5E9C /* DebugProfile.swift in Sources */,
564+
D04BAE4CE37C303DDD0347B8 /* CollectionViewItemView.swift in Sources */,
561565
);
562566
runOnlyForDeploymentPostprocessing = 0;
563567
};

alt-tab-macos/ui/main-window/CollectionViewFlowLayout.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ class CollectionViewFlowLayout: NSCollectionViewFlowLayout {
1717
var widestRow = CGFloat(0)
1818
var totalHeight = CGFloat(0)
1919
for (index, attribute) in attributes.enumerated() {
20-
let isNewRow = abs(attribute.frame.origin.y - currentRowY) > CollectionViewItem.height(currentScreen!)
20+
let isNewRow = abs(attribute.frame.origin.y - currentRowY) > CollectionViewItemView.height(currentScreen!)
2121
if isNewRow {
2222
currentRowWidth -= Preferences.interCellPadding
2323
widestRow = max(widestRow, currentRowWidth)
Lines changed: 3 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,12 @@
11
import Cocoa
22
import WebKit
33

4-
typealias MouseDownCallback = (Window) -> Void
5-
typealias MouseMovedCallback = (CollectionViewItem) -> Void
6-
74
class CollectionViewItem: NSCollectionViewItem {
8-
var thumbnail = NSImageView()
9-
var appIcon = NSImageView()
10-
var label = CellTitle(Preferences.fontHeight)
11-
var minimizedIcon = FontIcon(FontIcon.sfSymbolCircledMinusSign, Preferences.fontIconSize, .white)
12-
var hiddenIcon = FontIcon(FontIcon.sfSymbolCircledDotSign, Preferences.fontIconSize, .white)
13-
var spaceIcon = FontIcon(FontIcon.sfSymbolCircledNumber0, Preferences.fontIconSize, .white)
14-
var window: Window?
15-
var mouseDownCallback: MouseDownCallback?
16-
var mouseMovedCallback: MouseMovedCallback?
5+
var view_: CollectionViewItemView { view as! CollectionViewItemView }
176

187
override func loadView() {
19-
let hStackView = makeHStackView()
20-
let vStackView = makeVStackView(hStackView)
21-
let shadow = CollectionViewItem.makeShadow(.gray)
22-
thumbnail.shadow = shadow
23-
appIcon.shadow = shadow
24-
view = vStackView
25-
}
26-
27-
override func mouseMoved(with event: NSEvent) {
28-
mouseMovedCallback!(self)
29-
}
30-
31-
override func mouseDown(with theEvent: NSEvent) {
32-
mouseDownCallback!(window!)
8+
view = CollectionViewItemView()
9+
view.wantsLayer = true
3310
}
3411

3512
override var isSelected: Bool {
@@ -38,105 +15,4 @@ class CollectionViewItem: NSCollectionViewItem {
3815
view.layer!.borderColor = isSelected ? Preferences.highlightBorderColor.cgColor : .clear
3916
}
4017
}
41-
42-
func updateRecycledCellWithNewContent(_ element: Window, _ mouseDownCallback: @escaping MouseDownCallback, _ mouseMovedCallback: @escaping MouseMovedCallback, _ screen: NSScreen) {
43-
window = element
44-
thumbnail.image = element.thumbnail
45-
let (thumbnailWidth, thumbnailHeight) = CollectionViewItem.thumbnailSize(element.thumbnail, screen)
46-
let thumbnailSize = NSSize(width: thumbnailWidth.rounded(), height: thumbnailHeight.rounded())
47-
thumbnail.image?.size = thumbnailSize
48-
thumbnail.frame.size = thumbnailSize
49-
appIcon.image = element.icon
50-
let appIconSize = NSSize(width: Preferences.iconSize, height: Preferences.iconSize)
51-
appIcon.image?.size = appIconSize
52-
appIcon.frame.size = appIconSize
53-
label.string = element.title
54-
// workaround: setting string on NSTextView change the font (most likely a Cocoa bug)
55-
label.font = Preferences.font
56-
hiddenIcon.isHidden = !window!.isHidden
57-
minimizedIcon.isHidden = !window!.isMinimized
58-
spaceIcon.isHidden = element.spaceIndex == nil || Spaces.isSingleSpace || Preferences.hideSpaceNumberLabels
59-
if !spaceIcon.isHidden {
60-
if element.isOnAllSpaces {
61-
spaceIcon.setStar()
62-
} else {
63-
spaceIcon.setNumber(UInt32(element.spaceIndex!))
64-
}
65-
}
66-
let fontIconWidth = CGFloat([minimizedIcon, hiddenIcon, spaceIcon].filter { !$0.isHidden }.count) * (Preferences.fontIconSize + Preferences.intraCellPadding)
67-
label.textContainer!.size.width = view.frame.width - Preferences.iconSize - Preferences.intraCellPadding * 3 - fontIconWidth
68-
view.subviews.first!.frame.size = view.frame.size
69-
self.mouseDownCallback = mouseDownCallback
70-
self.mouseMovedCallback = mouseMovedCallback
71-
if view.trackingAreas.count > 0 {
72-
view.removeTrackingArea(view.trackingAreas[0])
73-
}
74-
view.addTrackingArea(NSTrackingArea(rect: view.bounds, options: [.mouseMoved, .activeAlways], owner: self, userInfo: nil))
75-
}
76-
77-
static func makeShadow(_ color: NSColor) -> NSShadow {
78-
let shadow = NSShadow()
79-
shadow.shadowColor = color
80-
shadow.shadowOffset = .zero
81-
shadow.shadowBlurRadius = 1
82-
return shadow
83-
}
84-
85-
private func makeHStackView() -> NSStackView {
86-
let hStackView = NSStackView()
87-
hStackView.spacing = Preferences.intraCellPadding
88-
hStackView.setViews([appIcon, label, hiddenIcon, minimizedIcon, spaceIcon], in: .leading)
89-
return hStackView
90-
}
91-
92-
private func makeVStackView(_ hStackView: NSStackView) -> NSStackView {
93-
let vStackView = NSStackView()
94-
vStackView.wantsLayer = true
95-
vStackView.layer!.backgroundColor = .clear
96-
vStackView.layer!.cornerRadius = Preferences.cellCornerRadius
97-
vStackView.layer!.borderWidth = Preferences.cellBorderWidth
98-
vStackView.layer!.borderColor = .clear
99-
vStackView.edgeInsets = NSEdgeInsets(top: Preferences.intraCellPadding, left: Preferences.intraCellPadding, bottom: Preferences.intraCellPadding, right: Preferences.intraCellPadding)
100-
vStackView.orientation = .vertical
101-
vStackView.spacing = Preferences.intraCellPadding
102-
vStackView.setViews([hStackView, thumbnail], in: .leading)
103-
return vStackView
104-
}
105-
106-
static func downscaleFactor() -> CGFloat {
107-
let nCellsBeforePotentialOverflow = Preferences.minRows * Preferences.minCellsPerRow
108-
guard CGFloat(Windows.list.count) > nCellsBeforePotentialOverflow else { return 1 }
109-
// TODO: replace this buggy heuristic with a correct implementation of downscaling
110-
return nCellsBeforePotentialOverflow / (nCellsBeforePotentialOverflow + (sqrt(CGFloat(Windows.list.count) - nCellsBeforePotentialOverflow) * 2))
111-
}
112-
113-
static func widthMax(_ screen: NSScreen) -> CGFloat {
114-
return (ThumbnailsPanel.widthMax(screen) / Preferences.minCellsPerRow - Preferences.interCellPadding) * CollectionViewItem.downscaleFactor()
115-
}
116-
117-
static func widthMin(_ screen: NSScreen) -> CGFloat {
118-
return (ThumbnailsPanel.widthMax(screen) / Preferences.maxCellsPerRow - Preferences.interCellPadding) * CollectionViewItem.downscaleFactor()
119-
}
120-
121-
static func height(_ screen: NSScreen) -> CGFloat {
122-
return (ThumbnailsPanel.heightMax(screen) / Preferences.minRows - Preferences.interCellPadding) * CollectionViewItem.downscaleFactor()
123-
}
124-
125-
static func width(_ image: NSImage?, _ screen: NSScreen) -> CGFloat {
126-
return max(thumbnailSize(image, screen).0 + Preferences.intraCellPadding * 2, CollectionViewItem.widthMin(screen))
127-
}
128-
129-
static func thumbnailSize(_ image: NSImage?, _ screen: NSScreen) -> (CGFloat, CGFloat) {
130-
guard let image = image else { return (0, 0) }
131-
let thumbnailHeightMax = CollectionViewItem.height(screen) - Preferences.intraCellPadding * 3 - Preferences.iconSize
132-
let thumbnailWidthMax = CollectionViewItem.widthMax(screen) - Preferences.intraCellPadding * 2
133-
let thumbnailHeight = min(image.size.height, thumbnailHeightMax)
134-
let thumbnailWidth = min(image.size.width, thumbnailWidthMax)
135-
let imageRatio = image.size.width / image.size.height
136-
let thumbnailRatio = thumbnailWidth / thumbnailHeight
137-
if thumbnailRatio > imageRatio {
138-
return (image.size.width * thumbnailHeight / image.size.height, thumbnailHeight)
139-
}
140-
return (thumbnailWidth, image.size.height * thumbnailWidth / image.size.width)
141-
}
14218
}

alt-tab-macos/ui/main-window/CollectionViewItemTitle.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class CellTitle: BaseLabel {
1414
self.init(NSRect.zero, textContainer)
1515
self.magicOffset = magicOffset
1616
textColor = Preferences.fontColor
17-
shadow = CollectionViewItem.makeShadow(.darkGray)
17+
shadow = CollectionViewItemView.makeShadow(.darkGray)
1818
defaultParagraphStyle = makeParagraphStyle(size)
1919
heightAnchor.constraint(equalToConstant: size + magicOffset).isActive = true
2020
}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import Cocoa
2+
3+
class CollectionViewItemView: NSView {
4+
var window_: Window?
5+
var thumbnail = NSImageView()
6+
var appIcon = NSImageView()
7+
var label = CellTitle(Preferences.fontHeight)
8+
var minimizedIcon = FontIcon(FontIcon.sfSymbolCircledMinusSign, Preferences.fontIconSize, .white)
9+
var hiddenIcon = FontIcon(FontIcon.sfSymbolCircledDotSign, Preferences.fontIconSize, .white)
10+
var spaceIcon = FontIcon(FontIcon.sfSymbolCircledNumber0, Preferences.fontIconSize, .white)
11+
var mouseDownCallback: MouseDownCallback!
12+
var mouseMovedCallback: MouseMovedCallback!
13+
var dragAndDropTimer: Timer?
14+
15+
convenience init() {
16+
self.init(frame: .zero)
17+
let hStackView = makeHStackView()
18+
let vStackView = makeVStackView(hStackView)
19+
let shadow = CollectionViewItemView.makeShadow(.gray)
20+
thumbnail.shadow = shadow
21+
appIcon.shadow = shadow
22+
observeDragAndDrop()
23+
subviews.append(vStackView)
24+
}
25+
26+
private func observeDragAndDrop() {
27+
// NSImageView instances are registered to drag-and-drop by default
28+
thumbnail.unregisterDraggedTypes()
29+
appIcon.unregisterDraggedTypes()
30+
// we only handle URLs (i.e. not text, image, or other draggable things)
31+
registerForDraggedTypes([NSPasteboard.PasteboardType(kUTTypeURL as String)])
32+
}
33+
34+
override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation {
35+
mouseMovedCallback()
36+
return .link
37+
}
38+
39+
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
40+
dragAndDropTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: false, block: { _ in
41+
self.mouseDownCallback()
42+
})
43+
return .link
44+
}
45+
46+
override func draggingExited(_ sender: NSDraggingInfo?) {
47+
dragAndDropTimer?.invalidate()
48+
dragAndDropTimer = nil
49+
}
50+
51+
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
52+
let urls = sender.draggingPasteboard.readObjects(forClasses: [NSURL.self]) as! [URL]
53+
let appUrl = window_!.application.runningApplication.bundleURL!
54+
let open = try? NSWorkspace.shared.open(urls, withApplicationAt: appUrl, options: [], configuration: [:])
55+
(App.shared as! App).hideUi()
56+
return open != nil
57+
}
58+
59+
override func mouseMoved(with event: NSEvent) {
60+
mouseMovedCallback()
61+
}
62+
63+
override func mouseDown(with theEvent: NSEvent) {
64+
mouseDownCallback()
65+
}
66+
67+
func updateRecycledCellWithNewContent(_ element: Window, _ mouseDownCallback: @escaping MouseDownCallback, _ mouseMovedCallback: @escaping MouseMovedCallback, _ screen: NSScreen) {
68+
window_ = element
69+
thumbnail.image = element.thumbnail
70+
let (thumbnailWidth, thumbnailHeight) = CollectionViewItemView.thumbnailSize(element.thumbnail, screen)
71+
let thumbnailSize = NSSize(width: thumbnailWidth.rounded(), height: thumbnailHeight.rounded())
72+
thumbnail.image?.size = thumbnailSize
73+
thumbnail.frame.size = thumbnailSize
74+
appIcon.image = element.icon
75+
let appIconSize = NSSize(width: Preferences.iconSize, height: Preferences.iconSize)
76+
appIcon.image?.size = appIconSize
77+
appIcon.frame.size = appIconSize
78+
label.string = element.title
79+
// workaround: setting string on NSTextView change the font (most likely a Cocoa bug)
80+
label.font = Preferences.font
81+
hiddenIcon.isHidden = !window_!.isHidden
82+
minimizedIcon.isHidden = !window_!.isMinimized
83+
spaceIcon.isHidden = element.spaceIndex == nil || Spaces.isSingleSpace || Preferences.hideSpaceNumberLabels
84+
if !spaceIcon.isHidden {
85+
if element.isOnAllSpaces {
86+
spaceIcon.setStar()
87+
} else {
88+
spaceIcon.setNumber(UInt32(element.spaceIndex!))
89+
}
90+
}
91+
let fontIconWidth = CGFloat([minimizedIcon, hiddenIcon, spaceIcon].filter { !$0.isHidden }.count) * (Preferences.fontIconSize + Preferences.intraCellPadding)
92+
label.textContainer!.size.width = frame.width - Preferences.iconSize - Preferences.intraCellPadding * 3 - fontIconWidth
93+
subviews.first!.frame.size = frame.size
94+
self.mouseDownCallback = mouseDownCallback
95+
self.mouseMovedCallback = mouseMovedCallback
96+
if trackingAreas.count > 0 {
97+
removeTrackingArea(trackingAreas[0])
98+
}
99+
addTrackingArea(NSTrackingArea(rect: bounds, options: [.mouseMoved, .activeAlways], owner: self, userInfo: nil))
100+
}
101+
102+
static func makeShadow(_ color: NSColor) -> NSShadow {
103+
let shadow = NSShadow()
104+
shadow.shadowColor = color
105+
shadow.shadowOffset = .zero
106+
shadow.shadowBlurRadius = 1
107+
return shadow
108+
}
109+
110+
static func downscaleFactor() -> CGFloat {
111+
let nCellsBeforePotentialOverflow = Preferences.minRows * Preferences.minCellsPerRow
112+
guard CGFloat(Windows.list.count) > nCellsBeforePotentialOverflow else { return 1 }
113+
// TODO: replace this buggy heuristic with a correct implementation of downscaling
114+
return nCellsBeforePotentialOverflow / (nCellsBeforePotentialOverflow + (sqrt(CGFloat(Windows.list.count) - nCellsBeforePotentialOverflow) * 2))
115+
}
116+
117+
static func widthMax(_ screen: NSScreen) -> CGFloat {
118+
return (ThumbnailsPanel.widthMax(screen) / Preferences.minCellsPerRow - Preferences.interCellPadding) * CollectionViewItemView.downscaleFactor()
119+
}
120+
121+
static func widthMin(_ screen: NSScreen) -> CGFloat {
122+
return (ThumbnailsPanel.widthMax(screen) / Preferences.maxCellsPerRow - Preferences.interCellPadding) * CollectionViewItemView.downscaleFactor()
123+
}
124+
125+
static func height(_ screen: NSScreen) -> CGFloat {
126+
return (ThumbnailsPanel.heightMax(screen) / Preferences.minRows - Preferences.interCellPadding) * CollectionViewItemView.downscaleFactor()
127+
}
128+
129+
static func width(_ image: NSImage?, _ screen: NSScreen) -> CGFloat {
130+
return max(thumbnailSize(image, screen).0 + Preferences.intraCellPadding * 2, CollectionViewItemView.widthMin(screen))
131+
}
132+
133+
static func thumbnailSize(_ image: NSImage?, _ screen: NSScreen) -> (CGFloat, CGFloat) {
134+
guard let image = image else { return (0, 0) }
135+
let thumbnailHeightMax = CollectionViewItemView.height(screen) - Preferences.intraCellPadding * 3 - Preferences.iconSize
136+
let thumbnailWidthMax = CollectionViewItemView.widthMax(screen) - Preferences.intraCellPadding * 2
137+
let thumbnailHeight = min(image.size.height, thumbnailHeightMax)
138+
let thumbnailWidth = min(image.size.width, thumbnailWidthMax)
139+
let imageRatio = image.size.width / image.size.height
140+
let thumbnailRatio = thumbnailWidth / thumbnailHeight
141+
if thumbnailRatio > imageRatio {
142+
return (image.size.width * thumbnailHeight / image.size.height, thumbnailHeight)
143+
}
144+
return (thumbnailWidth, image.size.height * thumbnailWidth / image.size.width)
145+
}
146+
147+
private func makeHStackView() -> NSStackView {
148+
let hStackView = NSStackView()
149+
hStackView.spacing = Preferences.intraCellPadding
150+
hStackView.setViews([appIcon, label, hiddenIcon, minimizedIcon, spaceIcon], in: .leading)
151+
return hStackView
152+
}
153+
154+
private func makeVStackView(_ hStackView: NSStackView) -> NSStackView {
155+
let vStackView = NSStackView()
156+
vStackView.wantsLayer = true
157+
vStackView.layer!.backgroundColor = .clear
158+
vStackView.layer!.cornerRadius = Preferences.cellCornerRadius
159+
vStackView.layer!.borderWidth = Preferences.cellBorderWidth
160+
vStackView.layer!.borderColor = .clear
161+
vStackView.edgeInsets = NSEdgeInsets(top: Preferences.intraCellPadding, left: Preferences.intraCellPadding, bottom: Preferences.intraCellPadding, right: Preferences.intraCellPadding)
162+
vStackView.orientation = .vertical
163+
vStackView.spacing = Preferences.intraCellPadding
164+
vStackView.setViews([hStackView, thumbnail], in: .leading)
165+
return vStackView
166+
}
167+
}
168+
169+
typealias MouseDownCallback = () -> Void
170+
typealias MouseMovedCallback = () -> Void

0 commit comments

Comments
 (0)