From ceed334b069ab35d204816bd23806b596de5fef6 Mon Sep 17 00:00:00 2001 From: lwouis Date: Thu, 12 Feb 2026 22:47:35 +0100 Subject: [PATCH] feat: support app-icon badges with large numbers --- src/ui/main-window/TileFontIconView.swift | 258 ++++++++++++++++++---- src/ui/main-window/TileView.swift | 53 +---- src/ui/main-window/TilesView.swift | 3 +- 3 files changed, 222 insertions(+), 92 deletions(-) diff --git a/src/ui/main-window/TileFontIconView.swift b/src/ui/main-window/TileFontIconView.swift index c09da326..8113ccc3 100644 --- a/src/ui/main-window/TileFontIconView.swift +++ b/src/ui/main-window/TileFontIconView.swift @@ -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 } } diff --git a/src/ui/main-window/TileView.swift b/src/ui/main-window/TileView.swift index 5946ae4d..9c46f32f 100644 --- a/src/ui/main-window/TileView.swift +++ b/src/ui/main-window/TileView.swift @@ -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 diff --git a/src/ui/main-window/TilesView.swift b/src/ui/main-window/TilesView.swift index 6737bb96..14d59776 100644 --- a/src/ui/main-window/TilesView.swift +++ b/src/ui/main-window/TilesView.swift @@ -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? } }