feat: support app-icon badges with large numbers
This commit is contained in:
1 parent
51db0cb8e4
commit
ceed334b06
3 files changed
+222
-92
No files matched your search
@@ -8,75 +8,237 @@ enum Symbols: String {
|
||||
case circledNumber10 = ""
|
||||
case circledStar = ""
|
||||
case filledCircledStar = ""
|
||||
case filledCircled = ""
|
||||
case filledCircledNumber0 = ""
|
||||
case filledCircledNumber10 = ""
|
||||
case circledInfo = ""
|
||||
}
|
||||
|
||||
// Font icon using SF Symbols from the SF Pro font from Apple
|
||||
// see https://developer.apple.com/design/human-interface-guidelines/sf-symbols/overview/
|
||||
class TileFontIconView: TileTitleView {
|
||||
static var paragraphStyle = {
|
||||
class TileFontIconView: NSView {
|
||||
enum Rendering {
|
||||
case symbol
|
||||
case badge
|
||||
}
|
||||
|
||||
struct SymbolCacheKey: Hashable {
|
||||
var symbol: String
|
||||
var size: CGFloat
|
||||
var colorKey: String
|
||||
}
|
||||
|
||||
static let paragraphStyle = {
|
||||
let paragraphStyle = NSMutableParagraphStyle()
|
||||
// clip the top of the box since we know these symbols are always disks
|
||||
paragraphStyle.lineHeightMultiple = 0.85
|
||||
return paragraphStyle
|
||||
}()
|
||||
var initialAttributedString: NSMutableAttributedString!
|
||||
|
||||
convenience init(symbol: Symbols, tooltip: String? = nil, size: CGFloat = Appearance.fontHeight, color: NSColor = Appearance.fontColor) {
|
||||
// This helps SF symbols display vertically centered and not clipped at the top
|
||||
self.init(font: NSFont(name: "SF Pro Text", size: (size * 0.85).rounded())!)
|
||||
initialAttributedString = NSMutableAttributedString(string: symbol.rawValue, attributes: [.paragraphStyle: TileFontIconView.paragraphStyle])
|
||||
attributedStringValue = initialAttributedString
|
||||
textColor = color
|
||||
toolTip = tooltip
|
||||
frame.size = cell!.cellSize
|
||||
private struct BadgeSizing {
|
||||
static let containerFromIconRatio = CGFloat(0.43)
|
||||
static let textFromContainerRatio = CGFloat(0.57)
|
||||
static let minContainerHeight = CGFloat(11)
|
||||
static let minTextHeight = CGFloat(8)
|
||||
static let maxTextHeight = CGFloat(18)
|
||||
static let horizontalPaddingRatio = CGFloat(0.10)
|
||||
static let minHorizontalPadding = CGFloat(2)
|
||||
static let appIconsMinRectWidthRatio = CGFloat(1.35)
|
||||
static let maxDigits = 4
|
||||
}
|
||||
|
||||
// number should be in the interval [0-50]
|
||||
func setNumber(_ number: Int, _ filled: Bool) {
|
||||
let (baseCharacter, offset) = baseCharacterAndOffset(number, filled)
|
||||
replaceCharIfNeeded(String(UnicodeScalar(Int(baseCharacter.unicodeScalars.first!.value) + offset)!))
|
||||
private struct BadgeMetrics {
|
||||
let containerHeight: CGFloat
|
||||
let textHeight: CGFloat
|
||||
}
|
||||
|
||||
private static var symbolCache = [SymbolCacheKey: NSAttributedString]()
|
||||
|
||||
static func badgeBaseSize(forIconSize iconSize: CGFloat) -> CGFloat {
|
||||
max((iconSize * BadgeSizing.containerFromIconRatio).rounded(), BadgeSizing.minContainerHeight)
|
||||
}
|
||||
|
||||
private let rendering: Rendering
|
||||
private let symbolSize: CGFloat
|
||||
private let symbolColor: NSColor
|
||||
private let badgeFillColor: NSColor
|
||||
private let badgeTextColor: NSColor
|
||||
private let symbolFont: NSFont
|
||||
private let badgeFont: NSFont
|
||||
private let badgeContainerHeight: CGFloat
|
||||
private let badgeHorizontalPadding: CGFloat
|
||||
private var text = ""
|
||||
private var cachedSymbolAttributedString: NSAttributedString?
|
||||
private var cachedBadgeAttributedString: NSAttributedString?
|
||||
private var cachedBadgeTextSize = NSSize.zero
|
||||
|
||||
convenience init(symbol: Symbols, tooltip: String? = nil, size: CGFloat = Appearance.fontHeight, color: NSColor = Appearance.fontColor) {
|
||||
self.init(rendering: .symbol, initialText: symbol.rawValue, size: size, symbolColor: color, badgeFillColor: .clear, badgeTextColor: .clear)
|
||||
toolTip = tooltip
|
||||
frame.size = symbolSizeForCurrentText()
|
||||
}
|
||||
|
||||
convenience init(badgeSize: CGFloat,
|
||||
fillColor: NSColor = NSColor(srgbRed: 1, green: 0.25, blue: 0.2, alpha: 0.9),
|
||||
textColor: NSColor = .white) {
|
||||
self.init(rendering: .badge, initialText: "0", size: badgeSize, symbolColor: .clear, badgeFillColor: fillColor, badgeTextColor: textColor)
|
||||
frame.size = badgeFrameSize(textWidth: maxBadgeTextWidth(), text: String(repeating: "8", count: BadgeSizing.maxDigits))
|
||||
}
|
||||
|
||||
init(rendering: Rendering,
|
||||
initialText: String,
|
||||
size: CGFloat,
|
||||
symbolColor: NSColor,
|
||||
badgeFillColor: NSColor,
|
||||
badgeTextColor: NSColor) {
|
||||
self.rendering = rendering
|
||||
self.symbolSize = size
|
||||
self.symbolColor = symbolColor
|
||||
self.badgeFillColor = badgeFillColor
|
||||
self.badgeTextColor = badgeTextColor
|
||||
symbolFont = NSFont(name: "SF Pro Text", size: (size * 0.85).rounded())!
|
||||
let badgeMetrics = Self.badgeMetrics(size)
|
||||
badgeContainerHeight = badgeMetrics.containerHeight
|
||||
badgeHorizontalPadding = Self.badgeHorizontalPadding(badgeMetrics.containerHeight)
|
||||
badgeFont = NSFont.systemFont(ofSize: badgeMetrics.textHeight)
|
||||
text = initialText
|
||||
super.init(frame: .zero)
|
||||
cachedSymbolAttributedString = rendering == .symbol ? cachedSymbolText(initialText) : nil
|
||||
if rendering == .badge {
|
||||
cachedBadgeAttributedString = badgeAttributedText(initialText)
|
||||
cachedBadgeTextSize = cachedBadgeAttributedString!.size()
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("Class only supports programmatic initialization")
|
||||
}
|
||||
|
||||
override var isOpaque: Bool { false }
|
||||
override var intrinsicContentSize: NSSize { frame.size }
|
||||
|
||||
static func warmCaches(symbols: [Symbols], size: CGFloat, color: NSColor) {
|
||||
let font = NSFont(name: "SF Pro Text", size: (size * 0.85).rounded())!
|
||||
let attributes: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: color, .paragraphStyle: paragraphStyle]
|
||||
let colorKey = symbolColorKey(color)
|
||||
for symbol in symbols {
|
||||
let key = SymbolCacheKey(symbol: symbol.rawValue, size: size, colorKey: colorKey)
|
||||
if symbolCache[key] == nil {
|
||||
symbolCache[key] = NSAttributedString(string: symbol.rawValue, attributes: attributes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setText(_ text: String) {
|
||||
guard text.count <= 4 && rendering == .badge else { setFilledStar(); return }
|
||||
replaceTextIfNeeded(text)
|
||||
}
|
||||
|
||||
func setStar() {
|
||||
replaceCharIfNeeded(Symbols.circledStar.rawValue)
|
||||
setStarLike(false)
|
||||
}
|
||||
|
||||
func setFilledStar() {
|
||||
replaceCharIfNeeded(Symbols.filledCircledStar.rawValue)
|
||||
setStarLike(true)
|
||||
}
|
||||
|
||||
private func replaceCharIfNeeded(_ newChar: String) {
|
||||
if newChar != attributedStringValue.string {
|
||||
initialAttributedString.replaceCharacters(in: NSRange(location: 0, length: 1), with: newChar)
|
||||
attributedStringValue = initialAttributedString
|
||||
}
|
||||
}
|
||||
|
||||
private func baseCharacterAndOffset(_ number: Int, _ filled: Bool) -> (String, Int) {
|
||||
if number <= 9 {
|
||||
// numbers alternate between empty and full circles; we skip the full circles
|
||||
return ((filled ? Symbols.filledCircledNumber0 : Symbols.circledNumber0).rawValue, number * 2)
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
if rendering == .badge {
|
||||
drawBadge()
|
||||
} else {
|
||||
return ((filled ? Symbols.filledCircledNumber10 : Symbols.circledNumber10).rawValue, number - 10)
|
||||
drawSymbol()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ThumbnailFilledFontIconView: NSView {
|
||||
convenience init(_ thumbnailFontIconView: TileFontIconView, backgroundColor: NSColor, size: CGFloat) {
|
||||
self.init(frame: .zero)
|
||||
let backgroundView = TileFontIconView(symbol: .filledCircled, size: size - 3, color: backgroundColor)
|
||||
addSubview(backgroundView)
|
||||
addSubview(thumbnailFontIconView, positioned: .above, relativeTo: nil)
|
||||
let fgSize = thumbnailFontIconView.frame.size
|
||||
let bgSize = backgroundView.frame.size
|
||||
let offset = ((fgSize.width - bgSize.width) / 2).rounded()
|
||||
thumbnailFontIconView.frame.origin = .zero
|
||||
backgroundView.frame.origin = NSPoint(x: (fgSize.width - bgSize.width) / 2, y: offset)
|
||||
frame.size = fgSize
|
||||
private func setStarLike(_ filled: Bool) {
|
||||
let star = rendering == .badge ? "" : (filled ? Symbols.filledCircledStar.rawValue : Symbols.circledStar.rawValue)
|
||||
replaceTextIfNeeded(star)
|
||||
}
|
||||
|
||||
private func replaceTextIfNeeded(_ newText: String) {
|
||||
guard newText != text else { return }
|
||||
text = newText
|
||||
if rendering == .symbol {
|
||||
cachedSymbolAttributedString = cachedSymbolText(newText)
|
||||
frame.size = symbolSizeForCurrentText()
|
||||
invalidateIntrinsicContentSize()
|
||||
} else {
|
||||
cachedBadgeAttributedString = badgeAttributedText(newText)
|
||||
cachedBadgeTextSize = cachedBadgeAttributedString!.size()
|
||||
}
|
||||
needsDisplay = true
|
||||
}
|
||||
|
||||
private func drawSymbol() {
|
||||
guard let cachedSymbolAttributedString else { return }
|
||||
cachedSymbolAttributedString.draw(at: .zero)
|
||||
}
|
||||
|
||||
private func drawBadge() {
|
||||
let badgeRect = anchoredBadgeRect()
|
||||
let innerRect = badgeRect
|
||||
let innerPath = NSBezierPath(roundedRect: innerRect, xRadius: innerRect.height / 2, yRadius: innerRect.height / 2)
|
||||
badgeFillColor.setFill()
|
||||
innerPath.fill()
|
||||
guard let cachedBadgeAttributedString else { return }
|
||||
let textSize = cachedBadgeTextSize
|
||||
let textPoint = NSPoint(x: innerRect.midX - textSize.width / 2, y: innerRect.midY - textSize.height / 2)
|
||||
cachedBadgeAttributedString.draw(at: textPoint)
|
||||
}
|
||||
|
||||
private func anchoredBadgeRect() -> NSRect {
|
||||
let size = badgeFrameSize(textWidth: cachedBadgeTextSize.width, text: text)
|
||||
return NSRect(x: (frame.width - size.width).rounded(), y: 0, width: size.width, height: size.height)
|
||||
}
|
||||
|
||||
private func symbolSizeForCurrentText() -> NSSize {
|
||||
cachedSymbolAttributedString?.size() ?? .zero
|
||||
}
|
||||
|
||||
private func badgeFrameSize(textWidth: CGFloat, text: String) -> NSSize {
|
||||
let height = badgeContainerHeight
|
||||
var width = max(height, textWidth + badgeHorizontalPadding * 2)
|
||||
if Preferences.appearanceStyle == .appIcons, text.count > 1 {
|
||||
let minRectWidth = (height * BadgeSizing.appIconsMinRectWidthRatio).rounded(.up)
|
||||
width = max(width, minRectWidth)
|
||||
}
|
||||
return NSSize(width: ceil(width), height: ceil(height))
|
||||
}
|
||||
|
||||
private func badgeAttributedText(_ value: String) -> NSAttributedString {
|
||||
NSAttributedString(string: value, attributes: [.font: badgeFont, .foregroundColor: badgeTextColor])
|
||||
}
|
||||
|
||||
private func badgeTextWidth(_ value: String) -> CGFloat {
|
||||
let attrs: [NSAttributedString.Key: Any] = [.font: badgeFont]
|
||||
return NSAttributedString(string: value, attributes: attrs).size().width
|
||||
}
|
||||
|
||||
private static func badgeHorizontalPadding(_ height: CGFloat) -> CGFloat {
|
||||
max(BadgeSizing.minHorizontalPadding, (height * BadgeSizing.horizontalPaddingRatio).rounded())
|
||||
}
|
||||
|
||||
private func maxBadgeTextWidth() -> CGFloat {
|
||||
badgeTextWidth(String(repeating: "8", count: BadgeSizing.maxDigits))
|
||||
}
|
||||
|
||||
private static func badgeMetrics(_ size: CGFloat) -> BadgeMetrics {
|
||||
let rawContainerHeight = max(BadgeSizing.minContainerHeight, size.rounded())
|
||||
let textHeight = badgeTextHeight(fromRawContainerHeight: rawContainerHeight)
|
||||
let compactContainerHeight = (textHeight / BadgeSizing.textFromContainerRatio).rounded(.up)
|
||||
let containerHeight = max(BadgeSizing.minContainerHeight, min(rawContainerHeight, compactContainerHeight))
|
||||
return BadgeMetrics(containerHeight: containerHeight, textHeight: textHeight)
|
||||
}
|
||||
|
||||
private static func badgeTextHeight(fromRawContainerHeight rawContainerHeight: CGFloat) -> CGFloat {
|
||||
min(max((rawContainerHeight * BadgeSizing.textFromContainerRatio).rounded(), BadgeSizing.minTextHeight), BadgeSizing.maxTextHeight)
|
||||
}
|
||||
|
||||
private func cachedSymbolText(_ value: String) -> NSAttributedString {
|
||||
let key = SymbolCacheKey(symbol: value, size: symbolSize, colorKey: Self.symbolColorKey(symbolColor))
|
||||
if let cached = Self.symbolCache[key] {
|
||||
return cached
|
||||
}
|
||||
let cached = NSAttributedString(string: value, attributes: [.font: symbolFont, .foregroundColor: symbolColor, .paragraphStyle: Self.paragraphStyle])
|
||||
Self.symbolCache[key] = cached
|
||||
return cached
|
||||
}
|
||||
|
||||
private static func symbolColorKey(_ color: NSColor) -> String {
|
||||
(color.usingColorSpace(.deviceRGB) ?? color).description
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,7 @@ class TileView: FlippedView {
|
||||
var appIcon = LightImageLayer()
|
||||
var label = TileTitleView(font: Appearance.font)
|
||||
var statusIcons = StatusIconsView()
|
||||
var dockLabelIcon = ThumbnailFilledFontIconView(
|
||||
TileFontIconView(symbol: .filledCircledNumber0, size: dockLabelLabelSize(), color: NSColor(srgbRed: 1, green: 0.30, blue: 0.25, alpha: 1)),
|
||||
backgroundColor: NSColor.white, size: dockLabelLabelSize())
|
||||
var dockLabelIcon = TileFontIconView(badgeSize: TileFontIconView.badgeBaseSize(forIconSize: TileView.iconSize().width))
|
||||
var windowlessAppIndicator = WindowlessAppIndicator(tooltip: TileView.noOpenWindowToolTip)
|
||||
|
||||
var mouseUpCallback: (() -> Void)!
|
||||
@@ -116,14 +114,8 @@ class TileView: FlippedView {
|
||||
func updateDockLabelIcon(_ dockLabel: String?) {
|
||||
assignIfDifferent(&dockLabelIcon.isHidden, dockLabel == nil || Preferences.hideAppBadges || Appearance.iconSize == 0)
|
||||
if !dockLabelIcon.isHidden, let dockLabel {
|
||||
let view = dockLabelIcon.subviews[1] as! TileFontIconView
|
||||
let dockLabelInt = Int(dockLabel)
|
||||
if dockLabelInt == nil || dockLabelInt! > 30 {
|
||||
view.setFilledStar()
|
||||
} else {
|
||||
view.setNumber(dockLabelInt!, true)
|
||||
}
|
||||
view.setAccessibilityLabel(getAccessibilityTextForBadge(dockLabel))
|
||||
dockLabelIcon.setText(dockLabel)
|
||||
dockLabelIcon.setAccessibilityLabel(getAccessibilityTextForBadge(dockLabel))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,33 +308,16 @@ class TileView: FlippedView {
|
||||
thumbnail.centerInSuperlayer(x: true)
|
||||
}
|
||||
updateWindowlessAppIndicatorPosition()
|
||||
// we set dockLabelIcon origin, without checking if .isHidden
|
||||
// This is because its updated async. We need it positioned correctly always
|
||||
let (offsetX, offsetY) = dockLabelOffset()
|
||||
assignIfDifferent(&dockLabelIcon.frame.origin.x, appIcon.frame.maxX - (TilesView.layoutCache.dockLabelSize.width * offsetX).rounded())
|
||||
assignIfDifferent(&dockLabelIcon.frame.origin.y, (TilesView.layoutCache.dockLabelSize.height * offsetY).rounded())
|
||||
updateDockLabelIconPosition()
|
||||
}
|
||||
|
||||
/// positioning the dock label is messy because it's an NSTextField so it's visual size doesn't match what we can through APIs
|
||||
// TODO: remove this; find a better way
|
||||
private func dockLabelOffset() -> (CGFloat, CGFloat) {
|
||||
var offsetX = 0.6
|
||||
if Preferences.appearanceStyle == .appIcons {
|
||||
if Preferences.appearanceSize == .small {
|
||||
offsetX = 0.82
|
||||
} else if Preferences.appearanceSize == .medium {
|
||||
offsetX = 0.86
|
||||
} else { // .large
|
||||
offsetX = 0.92
|
||||
}
|
||||
}
|
||||
var offsetY = 0.0
|
||||
if Preferences.appearanceStyle == .thumbnails {
|
||||
offsetY = 0.5
|
||||
} else if Preferences.appearanceStyle == .titles {
|
||||
offsetY = 0.1
|
||||
}
|
||||
return (offsetX, offsetY)
|
||||
private func updateDockLabelIconPosition() {
|
||||
let iconSize = max(appIcon.frame.width, appIcon.frame.height)
|
||||
let offset = (iconSize * (Preferences.appearanceStyle == .appIcons && Preferences.appearanceSize == .large ? 0.03 : 0.05)).rounded()
|
||||
let badgeTopRightX = appIcon.frame.maxX + offset
|
||||
let badgeTopRightY = appIcon.frame.minY - offset
|
||||
assignIfDifferent(&dockLabelIcon.frame.origin.x, badgeTopRightX - dockLabelIcon.frame.width)
|
||||
assignIfDifferent(&dockLabelIcon.frame.origin.y, badgeTopRightY)
|
||||
}
|
||||
|
||||
private func updateWindowlessAppIndicatorPosition() {
|
||||
@@ -412,12 +387,6 @@ class TileView: FlippedView {
|
||||
registerForDraggedTypes([NSPasteboard.PasteboardType(kUTTypeURL as String)])
|
||||
}
|
||||
|
||||
static func dockLabelLabelSize() -> CGFloat {
|
||||
let size = (TileView.iconSize().width * 0.43).rounded()
|
||||
// label should have a minimum size for readability
|
||||
return max(size, 13)
|
||||
}
|
||||
|
||||
static func makeShadow(_ color: NSColor?) -> NSShadow? {
|
||||
let shadow = NSShadow()
|
||||
shadow.shadowColor = color
|
||||
|
||||
@@ -25,8 +25,8 @@ class TilesView {
|
||||
let iconCellSize = firstView.statusIcons.iconCellSize
|
||||
layoutCache.iconWidth = iconCellSize.width
|
||||
layoutCache.iconHeight = iconCellSize.height
|
||||
layoutCache.dockLabelSize = firstView.dockLabelIcon.frame.size
|
||||
layoutCache.comfortableReadabilityWidth = TileView.widthOfComfortableReadability()
|
||||
TileFontIconView.warmCaches(symbols: [.circledPlusSign, .circledMinusSign, .circledSlashSign, .circledNumber0, .circledNumber10, .circledStar], size: Appearance.fontHeight, color: Appearance.fontColor)
|
||||
}
|
||||
|
||||
func updateBackgroundView() {
|
||||
@@ -318,7 +318,6 @@ class TilesView {
|
||||
var labelHeight = CGFloat(0)
|
||||
var iconWidth = CGFloat(0)
|
||||
var iconHeight = CGFloat(0)
|
||||
var dockLabelSize = NSSize.zero
|
||||
var comfortableReadabilityWidth: CGFloat?
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user