feat: add quit-app icon on mouse hover (closes #1260)
This commit is contained in:
1 parent
b460443e77
commit
5c3b5031d8
4 files changed
+117
-75
No files matched your search
@@ -11,6 +11,7 @@ class WorkspaceEvents {
|
||||
|
||||
static func observerCallback<A>(_ application: NSWorkspace, _ change: NSKeyValueObservedChange<A>) {
|
||||
let workspaceApps = Set(NSWorkspace.shared.runningApplications)
|
||||
// TODO: symmetricDifference has bad performance
|
||||
let diff = Array(workspaceApps.symmetricDifference(previousValueOfRunningApps))
|
||||
if change.kind == .insertion {
|
||||
debugPrint("OS event", "apps launched", diff.map { ($0.processIdentifier, $0.bundleIdentifier) })
|
||||
|
||||
@@ -3,10 +3,10 @@ import Foundation
|
||||
|
||||
class TrafficLightButton: NSButton {
|
||||
var isMouseOver = false
|
||||
var type: NSWindow.ButtonType!
|
||||
var type: TrafficLightButtonType!
|
||||
var window_: Window?
|
||||
|
||||
init(_ type: NSWindow.ButtonType, _ tooltip: String, _ size: CGFloat) {
|
||||
init(_ type: TrafficLightButtonType, _ tooltip: String, _ size: CGFloat) {
|
||||
super.init(frame: .init(origin: .zero, size: .init(width: size, height: size)))
|
||||
self.type = type
|
||||
target = self
|
||||
@@ -21,12 +21,14 @@ class TrafficLightButton: NSButton {
|
||||
}
|
||||
|
||||
@objc func onClick() {
|
||||
if (type == .zoomButton) {
|
||||
if (type == .fullscreen) {
|
||||
window_?.toggleFullscreen()
|
||||
} else if (type == .miniaturizeButton) {
|
||||
} else if (type == .miniaturize) {
|
||||
window_?.minDemin()
|
||||
} else if (type == .closeButton) {
|
||||
} else if (type == .close) {
|
||||
window_?.close()
|
||||
} else if (type == .quit) {
|
||||
window_?.application.quit()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,50 +43,10 @@ class TrafficLightButton: NSButton {
|
||||
}
|
||||
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
NSGraphicsContext.current?.saveGraphicsState()
|
||||
let (backgroundGradient, lineColor, strokeColor) = colors()
|
||||
let disk = drawDisk(backgroundGradient, strokeColor)
|
||||
drawSymbol(lineColor)
|
||||
let (diskBackgroundColor, diskStrokeColor, symbolColor) = colors()
|
||||
let disk = drawDisk(diskBackgroundColor, diskStrokeColor)
|
||||
drawSymbol(symbolColor)
|
||||
drawDimming(disk)
|
||||
NSGraphicsContext.current?.restoreGraphicsState()
|
||||
}
|
||||
|
||||
private func colors() -> (NSGradient, NSColor, NSColor) {
|
||||
if NSColor.currentControlTint == .graphiteControlTint {
|
||||
return (
|
||||
NSGradient(starting: NSColor(red: 0.57, green: 0.57, blue: 0.60, alpha: 1),
|
||||
ending: NSColor(red: 0.56, green: 0.55, blue: 0.57, alpha: 1))!,
|
||||
type == .zoomButton ?
|
||||
NSColor(red: 0.16, green: 0.16, blue: 0.17, alpha: 1)
|
||||
: type == .miniaturizeButton ?
|
||||
NSColor(red: 0.35, green: 0.35, blue: 0.37, alpha: 1)
|
||||
:
|
||||
NSColor(red: 0.19, green: 0.18, blue: 0.20, alpha: 1),
|
||||
NSColor(red: 0.51, green: 0.51, blue: 0.53, alpha: 1)
|
||||
)
|
||||
}
|
||||
if type == .zoomButton {
|
||||
return (
|
||||
NSGradient(starting: NSColor(red: 0.153, green: 0.788, blue: 0.247, alpha: 1),
|
||||
ending: NSColor(red: 0.153, green: 0.816, blue: 0.255, alpha: 1))!,
|
||||
NSColor(red: 0.004, green: 0.392, blue: 0, alpha: 1),
|
||||
NSColor(red: 0.180, green: 0.690, blue: 0.235, alpha: 1)
|
||||
)
|
||||
}
|
||||
if type == .miniaturizeButton {
|
||||
return (
|
||||
NSGradient(starting: NSColor(red: 1, green: 0.741, blue: 0.180, alpha: 1),
|
||||
ending: NSColor(red: 1, green: 0.773, blue: 0.184, alpha: 1))!,
|
||||
NSColor(red: 0.600, green: 0.345, blue: 0.004, alpha: 1),
|
||||
NSColor(red: 0.875, green: 0.616, blue: 0.094, alpha: 1)
|
||||
)
|
||||
}
|
||||
return (
|
||||
NSGradient(starting: NSColor(red: 1, green: 0.373, blue: 0.337, alpha: 1),
|
||||
ending: NSColor(red: 1, green: 0.388, blue: 0.357, alpha: 1))!,
|
||||
NSColor(red: 0.302, green: 0, blue: 0, alpha: 1),
|
||||
NSColor(red: 0.886, green: 0.243, blue: 0.216, alpha: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private func drawDimming(_ disk: NSBezierPath) {
|
||||
@@ -108,8 +70,54 @@ class TrafficLightButton: NSButton {
|
||||
return disk
|
||||
}
|
||||
|
||||
private func colors() -> (NSGradient, NSColor, NSColor) {
|
||||
if NSColor.currentControlTint == .graphiteControlTint {
|
||||
return (
|
||||
NSGradient(starting: NSColor(red: 0.57, green: 0.57, blue: 0.60, alpha: 1),
|
||||
ending: NSColor(red: 0.56, green: 0.55, blue: 0.57, alpha: 1))!,
|
||||
NSColor(red: 0.51, green: 0.51, blue: 0.53, alpha: 1),
|
||||
type == .fullscreen ?
|
||||
NSColor(red: 0.16, green: 0.16, blue: 0.17, alpha: 1)
|
||||
: type == .miniaturize ?
|
||||
NSColor(red: 0.35, green: 0.35, blue: 0.37, alpha: 1)
|
||||
:
|
||||
NSColor(red: 0.19, green: 0.18, blue: 0.20, alpha: 1)
|
||||
)
|
||||
}
|
||||
if type == .fullscreen {
|
||||
return (
|
||||
NSGradient(starting: NSColor(red: 0.153, green: 0.788, blue: 0.247, alpha: 1),
|
||||
ending: NSColor(red: 0.153, green: 0.816, blue: 0.255, alpha: 1))!,
|
||||
NSColor(red: 0.180, green: 0.690, blue: 0.235, alpha: 1),
|
||||
NSColor(red: 0.004, green: 0.392, blue: 0, alpha: 1)
|
||||
)
|
||||
}
|
||||
if type == .miniaturize {
|
||||
return (
|
||||
NSGradient(starting: NSColor(red: 1, green: 0.741, blue: 0.180, alpha: 1),
|
||||
ending: NSColor(red: 1, green: 0.773, blue: 0.184, alpha: 1))!,
|
||||
NSColor(red: 0.875, green: 0.616, blue: 0.094, alpha: 1),
|
||||
NSColor(red: 0.600, green: 0.345, blue: 0.004, alpha: 1)
|
||||
)
|
||||
}
|
||||
if type == .close {
|
||||
return (
|
||||
NSGradient(starting: NSColor(red: 1, green: 0.373, blue: 0.337, alpha: 1),
|
||||
ending: NSColor(red: 1, green: 0.388, blue: 0.357, alpha: 1))!,
|
||||
NSColor(red: 0.886, green: 0.243, blue: 0.216, alpha: 1),
|
||||
NSColor(red: 0.302, green: 0, blue: 0, alpha: 1)
|
||||
)
|
||||
}
|
||||
return (
|
||||
NSGradient(starting: NSColor(red: 0.74, green: 0.32, blue: 1, alpha: 1),
|
||||
ending: NSColor(red: 0.77, green: 0.35, blue: 1, alpha: 1))!,
|
||||
NSColor(red: 0.62, green: 0.23, blue: 0.88, alpha: 1),
|
||||
NSColor(red: 0.25, green: 0, blue: 0.4, alpha: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private func drawSymbol(_ lineColor: NSColor) {
|
||||
if (type == NSWindow.ButtonType.zoomButton) {
|
||||
if (type == .fullscreen) {
|
||||
let symbol = NSBezierPath()
|
||||
symbol.move(to: NSMakePoint(bounds.width * 0.25, bounds.height * 0.75))
|
||||
symbol.line(to: NSMakePoint(bounds.width * 0.25, bounds.height * 1 / 3))
|
||||
@@ -132,16 +140,16 @@ class TrafficLightButton: NSButton {
|
||||
// symbol.line(to: NSMakePoint(bounds.width * 0.20, bounds.height / 2))
|
||||
// symbol.lineWidth = 0.75
|
||||
// NSGraphicsContext.current?.shouldAntialias = true
|
||||
} else if (type == NSWindow.ButtonType.miniaturizeButton) {
|
||||
} else if (type == .miniaturize) {
|
||||
NSGraphicsContext.current?.shouldAntialias = false
|
||||
let symbol = NSBezierPath()
|
||||
symbol.move(to: NSMakePoint(bounds.width * 0.80, bounds.height / 2))
|
||||
symbol.line(to: NSMakePoint(bounds.width * 0.20, bounds.height / 2))
|
||||
symbol.move(to: NSMakePoint(bounds.width * 0.20, bounds.height / 2))
|
||||
symbol.line(to: NSMakePoint(bounds.width * 0.80, bounds.height / 2))
|
||||
symbol.lineWidth = 0.75
|
||||
lineColor.setStroke()
|
||||
symbol.stroke()
|
||||
NSGraphicsContext.current?.shouldAntialias = true
|
||||
} else if (type == NSWindow.ButtonType.closeButton) {
|
||||
} else if (type == .close) {
|
||||
let symbol = NSBezierPath()
|
||||
symbol.move(to: NSMakePoint(bounds.width * 0.30, bounds.height * 0.30))
|
||||
symbol.line(to: NSMakePoint(bounds.width * 0.70, bounds.height * 0.70))
|
||||
@@ -150,10 +158,32 @@ class TrafficLightButton: NSButton {
|
||||
symbol.lineWidth = 1
|
||||
lineColor.setStroke()
|
||||
symbol.stroke()
|
||||
} else if (type == .quit) {
|
||||
let mouthAngle = CGFloat(80) / 2
|
||||
let symbol = NSBezierPath()
|
||||
symbol.appendArc(
|
||||
withCenter: NSMakePoint(bounds.width / 2, bounds.height / 2),
|
||||
radius: bounds.width * 0.27,
|
||||
startAngle: 180 + 90 + mouthAngle,
|
||||
endAngle: 180 + 360 + 90 - mouthAngle
|
||||
)
|
||||
symbol.lineWidth = 0.75
|
||||
lineColor.setStroke()
|
||||
symbol.stroke()
|
||||
symbol.move(to: NSMakePoint(bounds.width / 2, bounds.height * 0.15))
|
||||
symbol.line(to: NSMakePoint(bounds.width / 2, bounds.height * 0.50))
|
||||
symbol.lineWidth = 1.2
|
||||
symbol.stroke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum TrafficLightButtonType {
|
||||
case quit
|
||||
case close
|
||||
case miniaturize
|
||||
case fullscreen
|
||||
}
|
||||
|
||||
|
||||
// experiment: use actual buttons from OS through standardWindowButton
|
||||
|
||||
@@ -17,13 +17,16 @@ enum Symbols: String {
|
||||
class FontIcon: NSView {
|
||||
var labelView: BaseLabel!
|
||||
|
||||
convenience init(_ symbol: Symbols, _ tooltip: String?, _ color: NSColor = .white) {
|
||||
convenience init(_ symbol: Symbols, _ tooltip: String?) {
|
||||
self.init(frame: .zero)
|
||||
shadow = ThumbnailView.makeShadow(.darkGray)
|
||||
wantsLayer = true
|
||||
layer!.cornerRadius = 2
|
||||
layer!.backgroundColor = NSColor(red: 0.9, green: 0.9, blue: 0.9, alpha: 1).cgColor
|
||||
labelView = BaseLabel(symbol.rawValue)
|
||||
labelView.font = NSFont(name: "SF Pro Text", size: Preferences.fontHeight)!
|
||||
labelView.textColor = color
|
||||
labelView.textColor = .darkGray
|
||||
labelView.alignment = .center
|
||||
labelView.shadow = ThumbnailView.makeShadow(.darkGray)
|
||||
labelView.toolTip = tooltip
|
||||
addSubview(labelView)
|
||||
}
|
||||
|
||||
@@ -12,9 +12,10 @@ class ThumbnailView: NSStackView {
|
||||
var hiddenIcon = ThumbnailFontIconView(.circledSlashSign, NSLocalizedString("App is hidden", comment: ""))
|
||||
var spaceIcon = ThumbnailFontIconView(.circledNumber0, nil)
|
||||
var dockLabelIcon = ThumbnailFilledFontIconView(ThumbnailFontIconView(.filledCircledNumber0, nil, 14, NSColor(srgbRed: 1, green: 0.30, blue: 0.25, alpha: 1), nil), NSColor.white)
|
||||
var closeIcon = TrafficLightButton(.closeButton, NSLocalizedString("Close window", comment: ""), windowsControlSize)
|
||||
var minimizeIcon = TrafficLightButton(.miniaturizeButton, NSLocalizedString("Minimize/Deminimize window", comment: ""), windowsControlSize)
|
||||
var maximizeIcon = TrafficLightButton(.zoomButton, NSLocalizedString("Fullscreen window", comment: ""), windowsControlSize)
|
||||
var quitIcon = TrafficLightButton(.quit, NSLocalizedString("Quit app", comment: ""), windowsControlSize)
|
||||
var closeIcon = TrafficLightButton(.close, NSLocalizedString("Close window", comment: ""), windowsControlSize)
|
||||
var minimizeIcon = TrafficLightButton(.miniaturize, NSLocalizedString("Minimize/Deminimize window", comment: ""), windowsControlSize)
|
||||
var maximizeIcon = TrafficLightButton(.fullscreen, NSLocalizedString("Fullscreen window", comment: ""), windowsControlSize)
|
||||
var hStackView: NSStackView!
|
||||
var mouseUpCallback: (() -> Void)!
|
||||
var mouseMovedCallback: (() -> Void)!
|
||||
@@ -46,6 +47,7 @@ class ThumbnailView: NSStackView {
|
||||
orientation = .vertical
|
||||
let shadow = ThumbnailView.makeShadow(.gray)
|
||||
thumbnail.shadow = shadow
|
||||
windowlessIcon.shadow = shadow
|
||||
appIcon.shadow = shadow
|
||||
hStackView = NSStackView(views: [appIcon, label, hiddenIcon, fullscreenIcon, minimizedIcon, spaceIcon])
|
||||
setViews([hStackView, thumbnail, windowlessIcon], in: .leading)
|
||||
@@ -59,24 +61,30 @@ class ThumbnailView: NSStackView {
|
||||
}
|
||||
|
||||
private func addWindowControls() {
|
||||
thumbnail.addSubview(quitIcon, positioned: .above, relativeTo: nil)
|
||||
thumbnail.addSubview(closeIcon, positioned: .above, relativeTo: nil)
|
||||
thumbnail.addSubview(minimizeIcon, positioned: .above, relativeTo: nil)
|
||||
thumbnail.addSubview(maximizeIcon, positioned: .above, relativeTo: nil)
|
||||
[closeIcon, minimizeIcon, maximizeIcon].forEach { $0.isHidden = true }
|
||||
[quitIcon, closeIcon, minimizeIcon, maximizeIcon].forEach { $0.isHidden = true }
|
||||
}
|
||||
|
||||
func showOrHideWindowControls(_ shouldShowWindowControls: Bool) {
|
||||
let shouldShow = shouldShowWindowControls && !Preferences.hideColoredCircles && !(window_?.isWindowlessApp ?? true) && !Preferences.hideThumbnails
|
||||
let shouldShow = shouldShowWindowControls && !Preferences.hideColoredCircles && !Preferences.hideThumbnails
|
||||
if isShowingWindowControls != shouldShow {
|
||||
isShowingWindowControls = shouldShow
|
||||
[closeIcon, minimizeIcon, maximizeIcon].forEach { $0.isHidden = !shouldShow }
|
||||
}
|
||||
if isShowingWindowControls {
|
||||
[closeIcon, minimizeIcon, maximizeIcon].enumerated().forEach { (i, icon: TrafficLightButton) in
|
||||
icon.setFrameOrigin(.init(
|
||||
x: 3 + (ThumbnailView.windowsControlSpacing + ThumbnailView.windowsControlSize) * CGFloat(i),
|
||||
y: thumbnail.frame.height - ThumbnailView.windowsControlSize - 2))
|
||||
icon.window_ = window_
|
||||
let target = (window_?.isWindowlessApp ?? true) ? windowlessIcon : thumbnail
|
||||
target.addSubview(quitIcon, positioned: .above, relativeTo: nil)
|
||||
var offset = CGFloat(0)
|
||||
[quitIcon, closeIcon, minimizeIcon, maximizeIcon].forEach { icon in
|
||||
icon.isHidden = !shouldShow ||
|
||||
((window_?.isWindowlessApp ?? true) && icon.type != .quit) ||
|
||||
(icon.type == .quit && window_?.application.runningApplication.bundleIdentifier == "com.apple.finder" && !Preferences.finderShowsQuitMenuItem)
|
||||
if !icon.isHidden {
|
||||
icon.setFrameOrigin(NSPoint(
|
||||
x: 3 + (ThumbnailView.windowsControlSpacing + ThumbnailView.windowsControlSize) * offset,
|
||||
y: target.frame.height - ThumbnailView.windowsControlSize - 2))
|
||||
offset += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,7 +113,7 @@ class ThumbnailView: NSStackView {
|
||||
|
||||
func updateRecycledCellWithNewContent(_ element: Window, _ index: Int, _ newHeight: CGFloat, _ screen: NSScreen) {
|
||||
window_ = element
|
||||
assignIfDifferent(&thumbnail.isHidden, Preferences.hideThumbnails)
|
||||
assignIfDifferent(&thumbnail.isHidden, Preferences.hideThumbnails || element.isWindowlessApp)
|
||||
if !thumbnail.isHidden {
|
||||
thumbnail.image = element.thumbnail
|
||||
if let image = thumbnail.image {
|
||||
@@ -154,7 +162,7 @@ class ThumbnailView: NSStackView {
|
||||
setAccessibilityHelp(getAccessibilityHelp(element.application.runningApplication.localizedName, element.dockLabel))
|
||||
}
|
||||
let widthMin = ThumbnailView.widthMin(screen)
|
||||
assignIfDifferent(&frame.size.width, max((Preferences.hideThumbnails ? hStackView.fittingSize.width : thumbnail.frame.size.width) + Preferences.intraCellPadding * 2, widthMin).rounded())
|
||||
assignIfDifferent(&frame.size.width, max((Preferences.hideThumbnails || element.isWindowlessApp ? hStackView.fittingSize.width : thumbnail.frame.size.width) + Preferences.intraCellPadding * 2, widthMin).rounded())
|
||||
assignIfDifferent(&frame.size.height, newHeight)
|
||||
let fontIconWidth = CGFloat([fullscreenIcon, minimizedIcon, hiddenIcon, spaceIcon].filter { !$0.isHidden }.count) * (Preferences.fontHeight + Preferences.intraCellPadding)
|
||||
assignIfDifferent(&label.textContainer!.size.width, frame.width - Preferences.iconSize - Preferences.intraCellPadding * 3 - fontIconWidth)
|
||||
@@ -164,16 +172,16 @@ class ThumbnailView: NSStackView {
|
||||
let maxWidth = (frame.size.width - Preferences.intraCellPadding * 2).rounded()
|
||||
let maxHeight = (frame.size.height - hStackView.fittingSize.height - Preferences.intraCellPadding * 2).rounded()
|
||||
// heuristic to determine font size based on bounding box
|
||||
let fontSize = (min(maxWidth, maxHeight) * 0.6).rounded()
|
||||
let fontSize = (min(maxWidth - Preferences.intraCellPadding * 2, maxHeight - Preferences.intraCellPadding * 2) * 0.6).rounded()
|
||||
// heuristic to fit the SF Symbol into the bounding box
|
||||
let labelViewHeight = (fontSize * 1.25).rounded()
|
||||
// 2.5 is a heuristic to have a _perceived_ vertical alignment on this particular icon
|
||||
windowlessIcon.labelView.frame = NSRect(x: 0, y: (maxHeight / 2 - labelViewHeight / 2.5).rounded(), width: maxWidth, height: labelViewHeight)
|
||||
let labelViewHeight = (fontSize * 1.4).rounded()
|
||||
windowlessIcon.labelView.frame = NSRect(x: Preferences.intraCellPadding, y: ((maxHeight - labelViewHeight) / 2).rounded(), width: maxWidth - Preferences.intraCellPadding * 2, height: labelViewHeight - Preferences.intraCellPadding * 2)
|
||||
windowlessIcon.labelView.font = NSFont(name: windowlessIcon.labelView.font!.fontName, size: fontSize)
|
||||
windowlessIcon.needsDisplay = true
|
||||
}
|
||||
self.mouseUpCallback = { () -> Void in App.app.focusSelectedWindow(element) }
|
||||
self.mouseMovedCallback = { () -> Void in Windows.updateFocusedWindowIndex(index) }
|
||||
[quitIcon, closeIcon, minimizeIcon, maximizeIcon].forEach { $0.window_ = element }
|
||||
// force a display to avoid flickering; see https://github.com/lwouis/alt-tab-macos/issues/197
|
||||
// quirk: display() should be called last as it resets thumbnail.frame.size somehow
|
||||
if labelChanged {
|
||||
|
||||
Reference in new issue
Block a user