feat: add quit-app icon on mouse hover (closes #1260)

This commit is contained in:
Louis Pontoise committed 2022-06-23 18:31:20 +09:00
1 parent b460443e77
commit 5c3b5031d8
4 files changed
+117 -75

No files matched your search

+1
View File
@@ -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)
}
+27 -19
View File
@@ -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 {