refactor: centralize font-relative UI icon sizing

This commit is contained in:
Valdemar Lorenzen
2026-10-15 13:16:12 +02:00
parent db8150c312
commit 6a7a0eb0ab
8 changed files with 124 additions and 46 deletions
+1 -1
View File
@@ -155,7 +155,7 @@ Numbered row overlays use 13-point numbers in 22×22-point boxes with a lighter
The actions selector is an arrowless panel inside the launcher, aligned to the bottom and right edges of the footer action pill, replacing it visually while open rather than floating above it. It shares the launcher's frosted Sunset background, 38-point selection row height, 22×22-point icon boxes with 12-point icon-to-text spacing, 14-point action and search text, rounded selection styling, and Return key badge; selection width follows the panel. Application and command rows use the same sizing constants. SF Symbol optical alignment insets are removed so command icons render inside the same bounds as application icons. The application name appears at the top and borderless action search sits eight points from the bottom, without a divider. Native animations crossfade the panel and footer pill: 160 milliseconds to open and 120 milliseconds to close, with unchanged geometry. Reduce Motion disables the animation. Rapid reopening invalidates stale dismissal completions, and closing panels immediately stop intercepting input. Escape, Command-K, an outside click, or switching away closes it and restores the pill. Closing while Conjure remains active restores launcher search focus; switching away does not steal focus back. Return in action search executes the highlighted action: Open launches the application, while Uninstall opens its review. The actions selector is an arrowless panel inside the launcher, aligned to the bottom and right edges of the footer action pill, replacing it visually while open rather than floating above it. It shares the launcher's frosted Sunset background, 38-point selection row height, 22×22-point icon boxes with 12-point icon-to-text spacing, 14-point action and search text, rounded selection styling, and Return key badge; selection width follows the panel. Application and command rows use the same sizing constants. SF Symbol optical alignment insets are removed so command icons render inside the same bounds as application icons. The application name appears at the top and borderless action search sits eight points from the bottom, without a divider. Native animations crossfade the panel and footer pill: 160 milliseconds to open and 120 milliseconds to close, with unchanged geometry. Reduce Motion disables the animation. Rapid reopening invalidates stale dismissal completions, and closing panels immediately stop intercepting input. Escape, Command-K, an outside click, or switching away closes it and restores the pill. Closing while Conjure remains active restores launcher search focus; switching away does not steal focus back. Return in action search executes the highlighted action: Open launches the application, while Uninstall opens its review.
Action symbols use the adjacent title's font size with the native medium optical scale and are never enlarged to fill the icon box. The reserved 22×22-point icon boxes, title positions, and row heights remain unchanged. `IconStyle` in `Sources/Conjure/IconStyle.swift` is the shared font-relative sizing policy: SF Symbols use the adjacent font's point size with native medium optical scale; application/file artwork slots use the font's point size multiplied by 22/14. `IconView` owns those slot constraints, down-only scaling, and zero optical alignment insets, so callers pass the same font used for their text rather than choosing separate icon dimensions. Launcher results and header, Actions, uninstall file rows, Back controls, and inline favorite markers use this policy. The approved Actions appearance remains unchanged; native button hit areas and the generated application-bundle icon are separate from artwork sizing.
Discovery includes the application bundle and exact bundle-ID paths in your Library: Preferences, Caches, Application Support, Logs, Saved Application State, Application Scripts, and Containers. Embedded extension identifiers are included. App-group entitlements are read from validated native code signatures; related shared Group Containers and Application Scripts remain unchecked because other applications may use them. Conjure does not guess name-based leftovers or include package receipts. This is conservative discovery, not a guarantee that every associated file is found. Discovery includes the application bundle and exact bundle-ID paths in your Library: Preferences, Caches, Application Support, Logs, Saved Application State, Application Scripts, and Containers. Embedded extension identifiers are included. App-group entitlements are read from validated native code signatures; related shared Group Containers and Application Scripts remain unchecked because other applications may use them. Conjure does not guess name-based leftovers or include package receipts. This is conservative discovery, not a guarantee that every associated file is found.
@@ -271,25 +271,24 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
private struct ActionEntry { private struct ActionEntry {
let view = LauncherRowView() let view = LauncherRowView()
let button: NSButton let button: NSButton
let icon = ActionIconView() let icon: IconView
let returnKey = ShortcutIndicator("↵") let returnKey = ShortcutIndicator("↵")
init(title: String, symbol: String, color: NSColor) { init(title: String, symbol: String, color: NSColor) {
let font = NSFont.systemFont(ofSize: LauncherRowView.textSize, weight: .medium)
icon = IconView(font: font)
button = NSButton(title: title, target: nil, action: nil) button = NSButton(title: title, target: nil, action: nil)
button.isBordered = false button.isBordered = false
button.alignment = .left button.alignment = .left
button.font = .systemFont(ofSize: LauncherRowView.textSize, weight: .medium) button.font = font
button.contentTintColor = color button.contentTintColor = color
button.attributedTitle = NSAttributedString(string: title, attributes: [ button.attributedTitle = NSAttributedString(string: title, attributes: [
.foregroundColor: color, .font: NSFont.systemFont(ofSize: LauncherRowView.textSize, weight: .medium) .foregroundColor: color, .font: font
]) ])
button.setAccessibilityLabel(title) button.setAccessibilityLabel(title)
icon.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) icon.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)
?? (symbol == "finder" ? NSWorkspace.shared.icon(forFile: "/System/Library/CoreServices/Finder.app") : nil) ?? (symbol == "finder" ? NSWorkspace.shared.icon(forFile: "/System/Library/CoreServices/Finder.app") : nil)
icon.contentTintColor = color icon.contentTintColor = color
icon.symbolConfiguration = .init(pointSize: LauncherRowView.textSize, weight: .regular, scale: .medium)
icon.imageScaling = .scaleProportionallyDown
icon.setAccessibilityElement(false)
for child in [icon, button, returnKey] { for child in [icon, button, returnKey] {
child.translatesAutoresizingMaskIntoConstraints = false child.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(child) view.addSubview(child)
@@ -298,8 +297,6 @@ private struct ActionEntry {
view.heightAnchor.constraint(equalToConstant: LauncherRowView.rowHeight), view.heightAnchor.constraint(equalToConstant: LauncherRowView.rowHeight),
icon.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 8), icon.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 8),
icon.centerYAnchor.constraint(equalTo: view.centerYAnchor), icon.centerYAnchor.constraint(equalTo: view.centerYAnchor),
icon.widthAnchor.constraint(equalToConstant: LauncherRowView.iconSize),
icon.heightAnchor.constraint(equalToConstant: LauncherRowView.iconSize),
button.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: LauncherRowView.iconToTextSpacing), button.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: LauncherRowView.iconToTextSpacing),
button.centerYAnchor.constraint(equalTo: view.centerYAnchor), button.centerYAnchor.constraint(equalTo: view.centerYAnchor),
button.trailingAnchor.constraint(equalTo: returnKey.leadingAnchor, constant: -12), button.trailingAnchor.constraint(equalTo: returnKey.leadingAnchor, constant: -12),
@@ -318,12 +315,6 @@ private final class ActionsPanelView: NSView {
} }
} }
@MainActor
private final class ActionIconView: NSImageView {
// SF Symbol optical insets otherwise enlarge a 22-point icon box beyond application icon sizing.
override var alignmentRectInsets: NSEdgeInsets { NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) }
}
@MainActor @MainActor
private final class ActionsSearchField: NSSearchField { private final class ActionsSearchField: NSSearchField {
var closeActions: (() -> Void)? var closeActions: (() -> Void)?
+40
View File
@@ -0,0 +1,40 @@
import AppKit
@MainActor
enum IconStyle {
// Preserve the approved 22-point application artwork slot beside 14-point text.
static func applicationSize(for font: NSFont) -> CGFloat {
font.pointSize * (22.0 / 14.0)
}
static func symbolConfiguration(for font: NSFont) -> NSImage.SymbolConfiguration {
.init(pointSize: font.pointSize, weight: .regular, scale: .medium)
}
static func symbol(_ name: String, for font: NSFont, accessibilityDescription: String? = nil) -> NSImage? {
NSImage(systemSymbolName: name, accessibilityDescription: accessibilityDescription)?
.withSymbolConfiguration(symbolConfiguration(for: font))
}
}
/// A shared artwork slot: font-sized symbols or proportionally sized application/file icons.
@MainActor
final class IconView: NSImageView {
init(font: NSFont) {
super.init(frame: .zero)
symbolConfiguration = IconStyle.symbolConfiguration(for: font)
imageScaling = .scaleProportionallyDown
setAccessibilityElement(false)
translatesAutoresizingMaskIntoConstraints = false
let size = IconStyle.applicationSize(for: font)
NSLayoutConstraint.activate([
widthAnchor.constraint(equalToConstant: size),
heightAnchor.constraint(equalToConstant: size)
])
}
// SF Symbol optical insets must not enlarge the shared artwork slot.
override var alignmentRectInsets: NSEdgeInsets { NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) }
required init?(coder: NSCoder) { fatalError("Conjure creates icons programmatically") }
}
+19 -21
View File
@@ -226,10 +226,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
searchField.delegate = self searchField.delegate = self
searchField.setAccessibilityLabel("Search applications, calculate, or convert units and currencies") searchField.setAccessibilityLabel("Search applications, calculate, or convert units and currencies")
let symbol = NSImageView() let symbol = IconView(font: searchFont)
symbol.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil) symbol.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)
symbol.contentTintColor = SunsetPalette.accent symbol.contentTintColor = SunsetPalette.accent
symbol.setAccessibilityElement(false)
let header = NSStackView(views: [symbol, searchField]) let header = NSStackView(views: [symbol, searchField])
header.orientation = .horizontal header.orientation = .horizontal
header.alignment = .centerY header.alignment = .centerY
@@ -324,8 +323,6 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
header.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16), header.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16),
header.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -24), header.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -24),
header.heightAnchor.constraint(equalToConstant: 32), header.heightAnchor.constraint(equalToConstant: 32),
symbol.widthAnchor.constraint(equalToConstant: 22),
symbol.heightAnchor.constraint(equalToConstant: 22),
sectionLabel.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 16), sectionLabel.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 16),
sectionLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16), sectionLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16),
scrollView.topAnchor.constraint(equalTo: sectionLabel.bottomAnchor, constant: -10), scrollView.topAnchor.constraint(equalTo: sectionLabel.bottomAnchor, constant: -10),
@@ -565,8 +562,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
cell.locationLabel.isHidden = true cell.locationLabel.isHidden = true
cell.kindLabel.stringValue = isCurrencyCalculation ? "Daily rates" cell.kindLabel.stringValue = isCurrencyCalculation ? "Daily rates"
: calculation.copyValue == nil ? "Error" : "Calculator" : calculation.copyValue == nil ? "Error" : "Calculator"
cell.imageView?.image = NSImage(systemSymbolName: "equal", accessibilityDescription: nil)? cell.imageView?.image = NSImage(systemSymbolName: "equal", accessibilityDescription: nil)
.withSymbolConfiguration(.init(pointSize: LauncherRowView.iconSize, weight: .regular))
cell.imageView?.contentTintColor = SunsetPalette.accent cell.imageView?.contentTintColor = SunsetPalette.accent
cell.toolTip = calculation.detail cell.toolTip = calculation.detail
} else if results[row].command != nil { } else if results[row].command != nil {
@@ -574,7 +570,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
cell.textField?.stringValue = entry.name cell.textField?.stringValue = entry.name
cell.textField?.setAccessibilityLabel(history.isFavorite(entry) ? "\(entry.name), Favorite" : nil) cell.textField?.setAccessibilityLabel(history.isFavorite(entry) ? "\(entry.name), Favorite" : nil)
cell.locationLabel.isHidden = true cell.locationLabel.isHidden = true
cell.kindLabel.stringValue = history.isFavorite(entry) ? "★ Command" : "Command" cell.kindLabel.attributedStringValue = history.isFavorite(entry)
? ApplicationCell.favoriteCommandKind : ApplicationCell.commandKind
cell.imageView?.image = NSImage(systemSymbolName: "character.bubble", accessibilityDescription: nil) cell.imageView?.image = NSImage(systemSymbolName: "character.bubble", accessibilityDescription: nil)
cell.imageView?.contentTintColor = SunsetPalette.accent cell.imageView?.contentTintColor = SunsetPalette.accent
cell.toolTip = "Translate English to Japanese" cell.toolTip = "Translate English to Japanese"
@@ -832,21 +829,26 @@ final class ApplicationCell: NSTableCellView {
private static let fadedMask = [NSColor.black.cgColor, NSColor.clear.cgColor] private static let fadedMask = [NSColor.black.cgColor, NSColor.clear.cgColor]
private static let timing = CAMediaTimingFunction(name: .easeOut) private static let timing = CAMediaTimingFunction(name: .easeOut)
static let applicationKind = NSAttributedString(string: "Application", attributes: [ static let applicationKind = NSAttributedString(string: "Application", attributes: [
.font: NSFont.systemFont(ofSize: 14), .foregroundColor: SunsetPalette.secondaryText .font: NSFont.systemFont(ofSize: LauncherRowView.textSize), .foregroundColor: SunsetPalette.secondaryText
]) ])
static let favoriteKind: NSAttributedString = { static let commandKind = NSAttributedString(string: "Command", attributes: applicationKind.attributes(at: 0, effectiveRange: nil))
static let favoriteKind = favorited(applicationKind)
static let favoriteCommandKind = favorited(commandKind)
private static func favorited(_ kind: NSAttributedString) -> NSAttributedString {
let font = kind.attribute(.font, at: 0, effectiveRange: nil) as! NSFont
let attachment = NSTextAttachment() let attachment = NSTextAttachment()
attachment.image = NSImage(systemSymbolName: "star.fill", accessibilityDescription: "Favorite")? attachment.image = NSImage(systemSymbolName: "star.fill", accessibilityDescription: "Favorite")?
.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 12, weight: .regular) .withSymbolConfiguration(IconStyle.symbolConfiguration(for: font)
.applying(.init(paletteColors: [SunsetPalette.secondaryText]))) .applying(.init(paletteColors: [SunsetPalette.secondaryText])))
let size = attachment.image?.size ?? .zero let size = attachment.image?.size ?? .zero
attachment.bounds = NSRect(x: 0, y: (NSFont.systemFont(ofSize: 14).capHeight - size.height) / 2, attachment.bounds = NSRect(x: 0, y: (font.capHeight - size.height) / 2,
width: size.width, height: size.height) width: size.width, height: size.height)
let title = NSMutableAttributedString(attachment: attachment) let title = NSMutableAttributedString(attachment: attachment)
title.append(NSAttributedString(string: " ")) title.append(NSAttributedString(string: " "))
title.append(applicationKind) title.append(kind)
return title return title
}() }
let locationLabel = NSTextField(labelWithString: "") let locationLabel = NSTextField(labelWithString: "")
let kindLabel = NSTextField(labelWithString: "Application") let kindLabel = NSTextField(labelWithString: "Application")
@@ -860,11 +862,10 @@ final class ApplicationCell: NSTableCellView {
override init(frame frameRect: NSRect) { override init(frame frameRect: NSRect) {
super.init(frame: frameRect) super.init(frame: frameRect)
let icon = NSImageView() let font = NSFont.systemFont(ofSize: LauncherRowView.textSize, weight: .medium)
icon.imageScaling = .scaleProportionallyDown let icon = IconView(font: font)
icon.setAccessibilityElement(false)
let label = NSTextField(labelWithString: "") let label = NSTextField(labelWithString: "")
label.font = .systemFont(ofSize: LauncherRowView.textSize, weight: .medium) label.font = font
label.textColor = SunsetPalette.text label.textColor = SunsetPalette.text
label.lineBreakMode = .byTruncatingTail label.lineBreakMode = .byTruncatingTail
locationLabel.font = .systemFont(ofSize: 12) locationLabel.font = .systemFont(ofSize: 12)
@@ -876,7 +877,7 @@ final class ApplicationCell: NSTableCellView {
title.orientation = .horizontal title.orientation = .horizontal
title.alignment = .centerY title.alignment = .centerY
title.spacing = 8 title.spacing = 8
kindLabel.font = .systemFont(ofSize: 14) kindLabel.font = .systemFont(ofSize: font.pointSize)
kindLabel.textColor = SunsetPalette.secondaryText kindLabel.textColor = SunsetPalette.secondaryText
kindLabel.setContentCompressionResistancePriority(.required, for: .horizontal) kindLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
kindLabel.setAccessibilityElement(false) kindLabel.setAccessibilityElement(false)
@@ -897,8 +898,6 @@ final class ApplicationCell: NSTableCellView {
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
icon.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), icon.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
icon.centerYAnchor.constraint(equalTo: centerYAnchor), icon.centerYAnchor.constraint(equalTo: centerYAnchor),
icon.widthAnchor.constraint(equalToConstant: LauncherRowView.iconSize),
icon.heightAnchor.constraint(equalToConstant: LauncherRowView.iconSize),
title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: LauncherRowView.iconToTextSpacing), title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: LauncherRowView.iconToTextSpacing),
title.centerYAnchor.constraint(equalTo: centerYAnchor), title.centerYAnchor.constraint(equalTo: centerYAnchor),
title.trailingAnchor.constraint(lessThanOrEqualTo: kindLabel.leadingAnchor, constant: -12), title.trailingAnchor.constraint(lessThanOrEqualTo: kindLabel.leadingAnchor, constant: -12),
@@ -1181,7 +1180,6 @@ final class LauncherPanel: NSPanel {
final class LauncherRowView: NSTableRowView { final class LauncherRowView: NSTableRowView {
static let rowHeight: CGFloat = 38 static let rowHeight: CGFloat = 38
static let textSize: CGFloat = 14 static let textSize: CGFloat = 14
static let iconSize: CGFloat = 22
static let iconToTextSpacing: CGFloat = 12 static let iconToTextSpacing: CGFloat = 12
var topSpacing: CGFloat = 0 { var topSpacing: CGFloat = 0 {
didSet { needsLayout = true; needsDisplay = true } didSet { needsLayout = true; needsDisplay = true }
+1 -1
View File
@@ -26,7 +26,7 @@ final class TranslationController: NSViewController, NSTextFieldDelegate, NSText
let title = NSTextField(labelWithString: "Conjure Translate · English → Japanese") let title = NSTextField(labelWithString: "Conjure Translate · English → Japanese")
title.font = .systemFont(ofSize: 13, weight: .semibold) title.font = .systemFont(ofSize: 13, weight: .semibold)
let back = NSButton(title: "", target: self, action: #selector(goBack)) let back = NSButton(title: "", target: self, action: #selector(goBack))
back.image = NSImage(systemSymbolName: "chevron.left", accessibilityDescription: nil) back.image = IconStyle.symbol("chevron.left", for: title.font!)
back.imageScaling = .scaleProportionallyDown back.imageScaling = .scaleProportionallyDown
back.isBordered = false back.isBordered = false
back.contentTintColor = SunsetPalette.secondaryText back.contentTintColor = SunsetPalette.secondaryText
@@ -88,7 +88,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
back.target = self back.target = self
back.action = #selector(goBack) back.action = #selector(goBack)
back.title = "" back.title = ""
back.image = NSImage(systemSymbolName: "chevron.left", accessibilityDescription: nil) back.image = IconStyle.symbol("chevron.left", for: search.font!)
back.imageScaling = .scaleProportionallyDown back.imageScaling = .scaleProportionallyDown
back.isBordered = false back.isBordered = false
back.contentTintColor = SunsetPalette.secondaryText back.contentTintColor = SunsetPalette.secondaryText
@@ -429,11 +429,13 @@ private final class UninstallFileCell: NSTableCellView {
let name = NSTextField(labelWithString: "") let name = NSTextField(labelWithString: "")
let detail = NSTextField(labelWithString: "") let detail = NSTextField(labelWithString: "")
let size = NSTextField(labelWithString: "") let size = NSTextField(labelWithString: "")
let fileIcon = NSImageView() let fileIcon: IconView
init() { init() {
let font = NSFont.systemFont(ofSize: 14, weight: .medium)
fileIcon = IconView(font: font)
super.init(frame: .zero) super.init(frame: .zero)
name.font = .systemFont(ofSize: 14, weight: .medium) name.font = font
detail.font = .systemFont(ofSize: 11) detail.font = .systemFont(ofSize: 11)
detail.textColor = SunsetPalette.secondaryText detail.textColor = SunsetPalette.secondaryText
for label in [name, detail] { for label in [name, detail] {
@@ -447,7 +449,6 @@ private final class UninstallFileCell: NSTableCellView {
size.textColor = SunsetPalette.secondaryText size.textColor = SunsetPalette.secondaryText
size.font = .systemFont(ofSize: 12) size.font = .systemFont(ofSize: 12)
size.setContentCompressionResistancePriority(.required, for: .horizontal) size.setContentCompressionResistancePriority(.required, for: .horizontal)
fileIcon.imageScaling = .scaleProportionallyDown
let row = NSStackView(views: [toggle, labels, NSView(), size, fileIcon]) let row = NSStackView(views: [toggle, labels, NSView(), size, fileIcon])
row.orientation = .horizontal row.orientation = .horizontal
row.alignment = .centerY row.alignment = .centerY
@@ -458,9 +459,7 @@ private final class UninstallFileCell: NSTableCellView {
row.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), row.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
row.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), row.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
row.topAnchor.constraint(equalTo: topAnchor, constant: 4), row.topAnchor.constraint(equalTo: topAnchor, constant: 4),
row.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4), row.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4)
fileIcon.widthAnchor.constraint(equalToConstant: 22),
fileIcon.heightAnchor.constraint(equalToConstant: 22)
]) ])
} }
+2 -2
View File
@@ -16,8 +16,8 @@
- [ ] Match uninstall footer text to the launcher/Translate baseline: 12pt medium rather than 14pt; retain destructive red. - [ ] Match uninstall footer text to the launcher/Translate baseline: 12pt medium rather than 14pt; retain destructive red.
- [ ] Align the uninstall footer with launcher/Translate: 8pt right/bottom insets rather than 24pt right and 16pt bottom. - [ ] Align the uninstall footer with launcher/Translate: 8pt right/bottom insets rather than 24pt right and 16pt bottom.
- [ ] Use the launcher/Translate footer's plain brown-filled view for uninstall instead of adding a popover material and orange tint. - [ ] Use the launcher/Translate footer's plain brown-filled view for uninstall instead of adding a popover material and orange tint.
- [ ] Apply Actions' SF Symbol optical-inset handling to launcher icons. Native layout measured a 22×29pt command icon view versus 22×22pt action icon views. - [x] Apply Actions' SF Symbol optical-inset handling to launcher icons. Native layout measured a 22×29pt command icon view versus 22×22pt action icon views.
- [ ] Render application and command favorite markers consistently: use the configured star symbol rather than a literal ★ for commands. - [x] Render application and command favorite markers consistently: use the configured star symbol rather than a literal ★ for commands.
- [ ] Apply the existing Sunset palette to Translate's custom heading and the Settings recovery button, matching equivalent headings/actions. - [ ] Apply the existing Sunset palette to Translate's custom heading and the Settings recovery button, matching equivalent headings/actions.
### Documentation and visual verification ### Documentation and visual verification
@@ -0,0 +1,50 @@
import AppKit
import Testing
@testable import Conjure
@Test @MainActor
func iconsScaleWithTypographyWithoutEnlargingSymbolsToApplicationSize() throws {
_ = NSApplication.shared
let artwork = NSImage(size: NSSize(width: 128, height: 128), flipped: false) { rect in
NSColor.white.setFill()
rect.fill()
return true
}
var rendered: [(application: CGFloat, symbol: CGFloat)] = []
for (textSize, expectedApplicationSize): (CGFloat, CGFloat) in [(14, 22), (28, 44)] {
let host = NSView(frame: NSRect(x: 0, y: 0, width: 100, height: 100))
let icon = IconView(font: .systemFont(ofSize: textSize, weight: .medium))
host.addSubview(icon)
NSLayoutConstraint.activate([
icon.centerXAnchor.constraint(equalTo: host.centerXAnchor),
icon.centerYAnchor.constraint(equalTo: host.centerYAnchor)
])
host.layoutSubtreeIfNeeded()
#expect(icon.frame.size == NSSize(width: expectedApplicationSize, height: expectedApplicationSize))
func artworkHeight() throws -> CGFloat {
let bitmap = try #require(icon.bitmapImageRepForCachingDisplay(in: icon.bounds))
icon.cacheDisplay(in: icon.bounds, to: bitmap)
var first = bitmap.pixelsHigh
var last = -1
for y in 0..<bitmap.pixelsHigh {
for x in 0..<bitmap.pixelsWide where (bitmap.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.5 {
first = min(first, y)
last = max(last, y)
}
}
return CGFloat(last - first + 1) * icon.bounds.height / CGFloat(bitmap.pixelsHigh)
}
icon.image = artwork
let applicationHeight = try artworkHeight()
// Reuse the same slot, as launcher cells do when switching to command/calculator results.
icon.image = NSImage(systemSymbolName: "stop.circle", accessibilityDescription: nil)
let symbolHeight = try artworkHeight()
#expect(applicationHeight == expectedApplicationSize)
#expect(symbolHeight > textSize * 0.75 && symbolHeight <= textSize)
icon.image = artwork
#expect(try artworkHeight() == applicationHeight)
rendered.append((applicationHeight, symbolHeight))
}
#expect(rendered[1].application == rendered[0].application * 2)
#expect(abs(rendered[1].symbol - rendered[0].symbol * 2) <= 1)
}