feat: support app-icon badges with large numbers

This commit is contained in:
lwouis committed 2026-02-16 13:33:18 +01:00
1 parent 51db0cb8e4
commit ceed334b06
3 files changed
+222 -92

No files matched your search

+210 -48
View File
@@ -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
}
}
+11 -42
View File
@@ -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
+1 -2
View File
@@ -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?
}
}