feat: drag-and-drop files on the ui (closes #74)

This commit is contained in:
louis.pontoise authored and lwouis committed 2020-03-10 14:00:37 +09:00
1 parent 7eb216d43c
commit e1e3633148
6 files changed
+187 -133

No files matched your search

+4
View File
@@ -53,6 +53,7 @@
D04BAD5A6B2F9EEE6FD4185F /* CollectionViewItemTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA4BABBA0312E0EDBA647 /* CollectionViewItemTitle.swift */; };
D04BAD8346A6A32C9749E0B3 /* TabViewItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA293C53EC5CE00D11E02 /* TabViewItem.swift */; };
D04BAE369A14C3126A1606FE /* HelperExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA8F1AA48A323EE5638DC /* HelperExtensions.swift */; };
D04BAE4CE37C303DDD0347B8 /* CollectionViewItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAE073DD0B0D65CD4CBB6 /* CollectionViewItemView.swift */; };
D04BAEAB8AB048FF2B16B131 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BAE5FA03065C5D23C0C2C /* Localizable.strings */; };
D04BAEF78503D7A2CEFB9E9E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAA44C837F3A67403B9DB /* main.swift */; };
D04BAF3B6F75E50E9AA3E1D2 /* LabelAndControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA3D65E7CA78D699EDAB0 /* LabelAndControl.swift */; };
@@ -133,6 +134,7 @@
D04BADBAFB42AE72DBE1E59E /* es */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = es; path = InfoPlist.strings; sourceTree = "<group>"; };
D04BADBCA16C1D448D34F473 /* en */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = en; path = InfoPlist.strings; sourceTree = "<group>"; };
D04BADCB1C0F50340A6CAFC2 /* Preferences.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; };
D04BAE073DD0B0D65CD4CBB6 /* CollectionViewItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CollectionViewItemView.swift; sourceTree = "<group>"; };
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>"; };
D04BAE23C37E0F3B07EEE7B1 /* AboutTab.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AboutTab.swift; sourceTree = "<group>"; };
D04BAE80772D25834E440975 /* Window.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Window.swift; sourceTree = "<group>"; };
@@ -442,6 +444,7 @@
D04BAF40D5E54AD1044B3FF7 /* ThumbnailsPanel.swift */,
D04BA4BABBA0312E0EDBA647 /* CollectionViewItemTitle.swift */,
D04BAC0416F29ADE7BC5A544 /* CollectionViewItemFontIcon.swift */,
D04BAE073DD0B0D65CD4CBB6 /* CollectionViewItemView.swift */,
);
path = "main-window";
sourceTree = "<group>";
@@ -558,6 +561,7 @@
D04BA6D9DA2A8BCD93347F0E /* CollectionViewItemFontIcon.swift in Sources */,
D04BA9EE5D34A2789DCB0EE2 /* Sysctl.swift in Sources */,
D04BAC4F69FE9563BC1C5E9C /* DebugProfile.swift in Sources */,
D04BAE4CE37C303DDD0347B8 /* CollectionViewItemView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -17,7 +17,7 @@ class CollectionViewFlowLayout: NSCollectionViewFlowLayout {
var widestRow = CGFloat(0)
var totalHeight = CGFloat(0)
for (index, attribute) in attributes.enumerated() {
let isNewRow = abs(attribute.frame.origin.y - currentRowY) > CollectionViewItem.height(currentScreen!)
let isNewRow = abs(attribute.frame.origin.y - currentRowY) > CollectionViewItemView.height(currentScreen!)
if isNewRow {
currentRowWidth -= Preferences.interCellPadding
widestRow = max(widestRow, currentRowWidth)
@@ -1,35 +1,12 @@
import Cocoa
import WebKit
typealias MouseDownCallback = (Window) -> Void
typealias MouseMovedCallback = (CollectionViewItem) -> Void
class CollectionViewItem: NSCollectionViewItem {
var thumbnail = NSImageView()
var appIcon = NSImageView()
var label = CellTitle(Preferences.fontHeight)
var minimizedIcon = FontIcon(FontIcon.sfSymbolCircledMinusSign, Preferences.fontIconSize, .white)
var hiddenIcon = FontIcon(FontIcon.sfSymbolCircledDotSign, Preferences.fontIconSize, .white)
var spaceIcon = FontIcon(FontIcon.sfSymbolCircledNumber0, Preferences.fontIconSize, .white)
var window: Window?
var mouseDownCallback: MouseDownCallback?
var mouseMovedCallback: MouseMovedCallback?
var view_: CollectionViewItemView { view as! CollectionViewItemView }
override func loadView() {
let hStackView = makeHStackView()
let vStackView = makeVStackView(hStackView)
let shadow = CollectionViewItem.makeShadow(.gray)
thumbnail.shadow = shadow
appIcon.shadow = shadow
view = vStackView
}
override func mouseMoved(with event: NSEvent) {
mouseMovedCallback!(self)
}
override func mouseDown(with theEvent: NSEvent) {
mouseDownCallback!(window!)
view = CollectionViewItemView()
view.wantsLayer = true
}
override var isSelected: Bool {
@@ -38,105 +15,4 @@ class CollectionViewItem: NSCollectionViewItem {
view.layer!.borderColor = isSelected ? Preferences.highlightBorderColor.cgColor : .clear
}
}
func updateRecycledCellWithNewContent(_ element: Window, _ mouseDownCallback: @escaping MouseDownCallback, _ mouseMovedCallback: @escaping MouseMovedCallback, _ screen: NSScreen) {
window = element
thumbnail.image = element.thumbnail
let (thumbnailWidth, thumbnailHeight) = CollectionViewItem.thumbnailSize(element.thumbnail, screen)
let thumbnailSize = NSSize(width: thumbnailWidth.rounded(), height: thumbnailHeight.rounded())
thumbnail.image?.size = thumbnailSize
thumbnail.frame.size = thumbnailSize
appIcon.image = element.icon
let appIconSize = NSSize(width: Preferences.iconSize, height: Preferences.iconSize)
appIcon.image?.size = appIconSize
appIcon.frame.size = appIconSize
label.string = element.title
// workaround: setting string on NSTextView change the font (most likely a Cocoa bug)
label.font = Preferences.font
hiddenIcon.isHidden = !window!.isHidden
minimizedIcon.isHidden = !window!.isMinimized
spaceIcon.isHidden = element.spaceIndex == nil || Spaces.isSingleSpace || Preferences.hideSpaceNumberLabels
if !spaceIcon.isHidden {
if element.isOnAllSpaces {
spaceIcon.setStar()
} else {
spaceIcon.setNumber(UInt32(element.spaceIndex!))
}
}
let fontIconWidth = CGFloat([minimizedIcon, hiddenIcon, spaceIcon].filter { !$0.isHidden }.count) * (Preferences.fontIconSize + Preferences.intraCellPadding)
label.textContainer!.size.width = view.frame.width - Preferences.iconSize - Preferences.intraCellPadding * 3 - fontIconWidth
view.subviews.first!.frame.size = view.frame.size
self.mouseDownCallback = mouseDownCallback
self.mouseMovedCallback = mouseMovedCallback
if view.trackingAreas.count > 0 {
view.removeTrackingArea(view.trackingAreas[0])
}
view.addTrackingArea(NSTrackingArea(rect: view.bounds, options: [.mouseMoved, .activeAlways], owner: self, userInfo: nil))
}
static func makeShadow(_ color: NSColor) -> NSShadow {
let shadow = NSShadow()
shadow.shadowColor = color
shadow.shadowOffset = .zero
shadow.shadowBlurRadius = 1
return shadow
}
private func makeHStackView() -> NSStackView {
let hStackView = NSStackView()
hStackView.spacing = Preferences.intraCellPadding
hStackView.setViews([appIcon, label, hiddenIcon, minimizedIcon, spaceIcon], in: .leading)
return hStackView
}
private func makeVStackView(_ hStackView: NSStackView) -> NSStackView {
let vStackView = NSStackView()
vStackView.wantsLayer = true
vStackView.layer!.backgroundColor = .clear
vStackView.layer!.cornerRadius = Preferences.cellCornerRadius
vStackView.layer!.borderWidth = Preferences.cellBorderWidth
vStackView.layer!.borderColor = .clear
vStackView.edgeInsets = NSEdgeInsets(top: Preferences.intraCellPadding, left: Preferences.intraCellPadding, bottom: Preferences.intraCellPadding, right: Preferences.intraCellPadding)
vStackView.orientation = .vertical
vStackView.spacing = Preferences.intraCellPadding
vStackView.setViews([hStackView, thumbnail], in: .leading)
return vStackView
}
static func downscaleFactor() -> CGFloat {
let nCellsBeforePotentialOverflow = Preferences.minRows * Preferences.minCellsPerRow
guard CGFloat(Windows.list.count) > nCellsBeforePotentialOverflow else { return 1 }
// TODO: replace this buggy heuristic with a correct implementation of downscaling
return nCellsBeforePotentialOverflow / (nCellsBeforePotentialOverflow + (sqrt(CGFloat(Windows.list.count) - nCellsBeforePotentialOverflow) * 2))
}
static func widthMax(_ screen: NSScreen) -> CGFloat {
return (ThumbnailsPanel.widthMax(screen) / Preferences.minCellsPerRow - Preferences.interCellPadding) * CollectionViewItem.downscaleFactor()
}
static func widthMin(_ screen: NSScreen) -> CGFloat {
return (ThumbnailsPanel.widthMax(screen) / Preferences.maxCellsPerRow - Preferences.interCellPadding) * CollectionViewItem.downscaleFactor()
}
static func height(_ screen: NSScreen) -> CGFloat {
return (ThumbnailsPanel.heightMax(screen) / Preferences.minRows - Preferences.interCellPadding) * CollectionViewItem.downscaleFactor()
}
static func width(_ image: NSImage?, _ screen: NSScreen) -> CGFloat {
return max(thumbnailSize(image, screen).0 + Preferences.intraCellPadding * 2, CollectionViewItem.widthMin(screen))
}
static func thumbnailSize(_ image: NSImage?, _ screen: NSScreen) -> (CGFloat, CGFloat) {
guard let image = image else { return (0, 0) }
let thumbnailHeightMax = CollectionViewItem.height(screen) - Preferences.intraCellPadding * 3 - Preferences.iconSize
let thumbnailWidthMax = CollectionViewItem.widthMax(screen) - Preferences.intraCellPadding * 2
let thumbnailHeight = min(image.size.height, thumbnailHeightMax)
let thumbnailWidth = min(image.size.width, thumbnailWidthMax)
let imageRatio = image.size.width / image.size.height
let thumbnailRatio = thumbnailWidth / thumbnailHeight
if thumbnailRatio > imageRatio {
return (image.size.width * thumbnailHeight / image.size.height, thumbnailHeight)
}
return (thumbnailWidth, image.size.height * thumbnailWidth / image.size.width)
}
}
@@ -14,7 +14,7 @@ class CellTitle: BaseLabel {
self.init(NSRect.zero, textContainer)
self.magicOffset = magicOffset
textColor = Preferences.fontColor
shadow = CollectionViewItem.makeShadow(.darkGray)
shadow = CollectionViewItemView.makeShadow(.darkGray)
defaultParagraphStyle = makeParagraphStyle(size)
heightAnchor.constraint(equalToConstant: size + magicOffset).isActive = true
}
@@ -0,0 +1,170 @@
import Cocoa
class CollectionViewItemView: NSView {
var window_: Window?
var thumbnail = NSImageView()
var appIcon = NSImageView()
var label = CellTitle(Preferences.fontHeight)
var minimizedIcon = FontIcon(FontIcon.sfSymbolCircledMinusSign, Preferences.fontIconSize, .white)
var hiddenIcon = FontIcon(FontIcon.sfSymbolCircledDotSign, Preferences.fontIconSize, .white)
var spaceIcon = FontIcon(FontIcon.sfSymbolCircledNumber0, Preferences.fontIconSize, .white)
var mouseDownCallback: MouseDownCallback!
var mouseMovedCallback: MouseMovedCallback!
var dragAndDropTimer: Timer?
convenience init() {
self.init(frame: .zero)
let hStackView = makeHStackView()
let vStackView = makeVStackView(hStackView)
let shadow = CollectionViewItemView.makeShadow(.gray)
thumbnail.shadow = shadow
appIcon.shadow = shadow
observeDragAndDrop()
subviews.append(vStackView)
}
private func observeDragAndDrop() {
// NSImageView instances are registered to drag-and-drop by default
thumbnail.unregisterDraggedTypes()
appIcon.unregisterDraggedTypes()
// we only handle URLs (i.e. not text, image, or other draggable things)
registerForDraggedTypes([NSPasteboard.PasteboardType(kUTTypeURL as String)])
}
override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation {
mouseMovedCallback()
return .link
}
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
dragAndDropTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: false, block: { _ in
self.mouseDownCallback()
})
return .link
}
override func draggingExited(_ sender: NSDraggingInfo?) {
dragAndDropTimer?.invalidate()
dragAndDropTimer = nil
}
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
let urls = sender.draggingPasteboard.readObjects(forClasses: [NSURL.self]) as! [URL]
let appUrl = window_!.application.runningApplication.bundleURL!
let open = try? NSWorkspace.shared.open(urls, withApplicationAt: appUrl, options: [], configuration: [:])
(App.shared as! App).hideUi()
return open != nil
}
override func mouseMoved(with event: NSEvent) {
mouseMovedCallback()
}
override func mouseDown(with theEvent: NSEvent) {
mouseDownCallback()
}
func updateRecycledCellWithNewContent(_ element: Window, _ mouseDownCallback: @escaping MouseDownCallback, _ mouseMovedCallback: @escaping MouseMovedCallback, _ screen: NSScreen) {
window_ = element
thumbnail.image = element.thumbnail
let (thumbnailWidth, thumbnailHeight) = CollectionViewItemView.thumbnailSize(element.thumbnail, screen)
let thumbnailSize = NSSize(width: thumbnailWidth.rounded(), height: thumbnailHeight.rounded())
thumbnail.image?.size = thumbnailSize
thumbnail.frame.size = thumbnailSize
appIcon.image = element.icon
let appIconSize = NSSize(width: Preferences.iconSize, height: Preferences.iconSize)
appIcon.image?.size = appIconSize
appIcon.frame.size = appIconSize
label.string = element.title
// workaround: setting string on NSTextView change the font (most likely a Cocoa bug)
label.font = Preferences.font
hiddenIcon.isHidden = !window_!.isHidden
minimizedIcon.isHidden = !window_!.isMinimized
spaceIcon.isHidden = element.spaceIndex == nil || Spaces.isSingleSpace || Preferences.hideSpaceNumberLabels
if !spaceIcon.isHidden {
if element.isOnAllSpaces {
spaceIcon.setStar()
} else {
spaceIcon.setNumber(UInt32(element.spaceIndex!))
}
}
let fontIconWidth = CGFloat([minimizedIcon, hiddenIcon, spaceIcon].filter { !$0.isHidden }.count) * (Preferences.fontIconSize + Preferences.intraCellPadding)
label.textContainer!.size.width = frame.width - Preferences.iconSize - Preferences.intraCellPadding * 3 - fontIconWidth
subviews.first!.frame.size = frame.size
self.mouseDownCallback = mouseDownCallback
self.mouseMovedCallback = mouseMovedCallback
if trackingAreas.count > 0 {
removeTrackingArea(trackingAreas[0])
}
addTrackingArea(NSTrackingArea(rect: bounds, options: [.mouseMoved, .activeAlways], owner: self, userInfo: nil))
}
static func makeShadow(_ color: NSColor) -> NSShadow {
let shadow = NSShadow()
shadow.shadowColor = color
shadow.shadowOffset = .zero
shadow.shadowBlurRadius = 1
return shadow
}
static func downscaleFactor() -> CGFloat {
let nCellsBeforePotentialOverflow = Preferences.minRows * Preferences.minCellsPerRow
guard CGFloat(Windows.list.count) > nCellsBeforePotentialOverflow else { return 1 }
// TODO: replace this buggy heuristic with a correct implementation of downscaling
return nCellsBeforePotentialOverflow / (nCellsBeforePotentialOverflow + (sqrt(CGFloat(Windows.list.count) - nCellsBeforePotentialOverflow) * 2))
}
static func widthMax(_ screen: NSScreen) -> CGFloat {
return (ThumbnailsPanel.widthMax(screen) / Preferences.minCellsPerRow - Preferences.interCellPadding) * CollectionViewItemView.downscaleFactor()
}
static func widthMin(_ screen: NSScreen) -> CGFloat {
return (ThumbnailsPanel.widthMax(screen) / Preferences.maxCellsPerRow - Preferences.interCellPadding) * CollectionViewItemView.downscaleFactor()
}
static func height(_ screen: NSScreen) -> CGFloat {
return (ThumbnailsPanel.heightMax(screen) / Preferences.minRows - Preferences.interCellPadding) * CollectionViewItemView.downscaleFactor()
}
static func width(_ image: NSImage?, _ screen: NSScreen) -> CGFloat {
return max(thumbnailSize(image, screen).0 + Preferences.intraCellPadding * 2, CollectionViewItemView.widthMin(screen))
}
static func thumbnailSize(_ image: NSImage?, _ screen: NSScreen) -> (CGFloat, CGFloat) {
guard let image = image else { return (0, 0) }
let thumbnailHeightMax = CollectionViewItemView.height(screen) - Preferences.intraCellPadding * 3 - Preferences.iconSize
let thumbnailWidthMax = CollectionViewItemView.widthMax(screen) - Preferences.intraCellPadding * 2
let thumbnailHeight = min(image.size.height, thumbnailHeightMax)
let thumbnailWidth = min(image.size.width, thumbnailWidthMax)
let imageRatio = image.size.width / image.size.height
let thumbnailRatio = thumbnailWidth / thumbnailHeight
if thumbnailRatio > imageRatio {
return (image.size.width * thumbnailHeight / image.size.height, thumbnailHeight)
}
return (thumbnailWidth, image.size.height * thumbnailWidth / image.size.width)
}
private func makeHStackView() -> NSStackView {
let hStackView = NSStackView()
hStackView.spacing = Preferences.intraCellPadding
hStackView.setViews([appIcon, label, hiddenIcon, minimizedIcon, spaceIcon], in: .leading)
return hStackView
}
private func makeVStackView(_ hStackView: NSStackView) -> NSStackView {
let vStackView = NSStackView()
vStackView.wantsLayer = true
vStackView.layer!.backgroundColor = .clear
vStackView.layer!.cornerRadius = Preferences.cellCornerRadius
vStackView.layer!.borderWidth = Preferences.cellBorderWidth
vStackView.layer!.borderColor = .clear
vStackView.edgeInsets = NSEdgeInsets(top: Preferences.intraCellPadding, left: Preferences.intraCellPadding, bottom: Preferences.intraCellPadding, right: Preferences.intraCellPadding)
vStackView.orientation = .vertical
vStackView.spacing = Preferences.intraCellPadding
vStackView.setViews([hStackView, thumbnail], in: .leading)
return vStackView
}
}
typealias MouseDownCallback = () -> Void
typealias MouseMovedCallback = () -> Void
@@ -21,8 +21,9 @@ class ThumbnailsPanel: NSPanel, NSCollectionViewDataSource, NSCollectionViewDele
backgroundView = ThumbnailsPanel.makeBackgroundView()
backgroundView.addSubview(collectionView)
contentView!.addSubview(backgroundView)
// highest level possible; this allows the app to go on top of context menus
level = .screenSaver
// 2nd highest level possible; this allows the app to go on top of context menus
// highest level is .screenSaver but makes drag and drop on top the main window impossible
level = .popUpMenu
// helps filter out this window from the thumbnails
setAccessibilitySubrole(.unknown)
}
@@ -65,13 +66,16 @@ class ThumbnailsPanel: NSPanel, NSCollectionViewDataSource, NSCollectionViewDele
func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem {
let item = collectionView.makeItem(withIdentifier: cellId, for: indexPath) as! CollectionViewItem
item.updateRecycledCellWithNewContent(Windows.list[indexPath.item], app!.focusSelectedWindow, app!.thumbnailsPanel!.highlightCell, currentScreen!)
item.view_.updateRecycledCellWithNewContent(Windows.list[indexPath.item],
{ self.app!.focusSelectedWindow(item.view_.window_) },
{ self.app!.thumbnailsPanel!.highlightCell(item) },
currentScreen!)
return item
}
func collectionView(_ collectionView: NSCollectionView, layout collectionViewLayout: NSCollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> NSSize {
guard indexPath.item < Windows.list.count else { return .zero }
return NSSize(width: CollectionViewItem.width(Windows.list[indexPath.item].thumbnail, currentScreen!).rounded(), height: CollectionViewItem.height(currentScreen!).rounded())
return NSSize(width: CollectionViewItemView.width(Windows.list[indexPath.item].thumbnail, currentScreen!).rounded(), height: CollectionViewItemView.height(currentScreen!).rounded())
}
func highlightCell() {