286 lines
12 KiB
Swift
286 lines
12 KiB
Swift
import AppKit
|
|
|
|
final class PaddedToggleButton: NSButton {
|
|
var contentPadding = NSEdgeInsets(top: 4, left: 7, bottom: 4, right: 7) {
|
|
didSet {
|
|
if oldValue.top != self.contentPadding.top ||
|
|
oldValue.left != self.contentPadding.left ||
|
|
oldValue.bottom != self.contentPadding.bottom ||
|
|
oldValue.right != self.contentPadding.right
|
|
{
|
|
self.invalidateIntrinsicContentSize()
|
|
}
|
|
}
|
|
}
|
|
|
|
override var intrinsicContentSize: NSSize {
|
|
let size = super.intrinsicContentSize
|
|
return NSSize(
|
|
width: size.width + self.contentPadding.left + self.contentPadding.right,
|
|
height: size.height + self.contentPadding.top + self.contentPadding.bottom)
|
|
}
|
|
}
|
|
|
|
final class InlineIconToggleButton: NSButton {
|
|
private let iconView = NSImageView()
|
|
private let titleField = NSTextField(labelWithString: "")
|
|
private let stack = NSStackView()
|
|
private var paddingConstraints: [NSLayoutConstraint] = []
|
|
private var iconSizeConstraints: [NSLayoutConstraint] = []
|
|
private var isConfiguring = false // Batch invalidation during setup
|
|
|
|
var contentPadding = NSEdgeInsets(top: 4, left: 7, bottom: 4, right: 7) {
|
|
didSet {
|
|
self.paddingConstraints.first { $0.firstAttribute == .top }?.constant = self.contentPadding.top
|
|
self.paddingConstraints.first { $0.firstAttribute == .leading }?.constant = self.contentPadding.left
|
|
self.paddingConstraints.first { $0.firstAttribute == .trailing }?.constant = -self.contentPadding.right
|
|
self.paddingConstraints.first { $0.firstAttribute == .bottom }?.constant = -self.contentPadding.bottom
|
|
if !self.isConfiguring { self.invalidateIntrinsicContentSize() }
|
|
}
|
|
}
|
|
|
|
override var title: String {
|
|
get { "" }
|
|
set {
|
|
super.title = ""
|
|
super.alternateTitle = ""
|
|
super.attributedTitle = NSAttributedString(string: "")
|
|
super.attributedAlternateTitle = NSAttributedString(string: "")
|
|
self.titleField.stringValue = newValue
|
|
self.setAccessibilityLabel(newValue)
|
|
if !self.isConfiguring { self.invalidateIntrinsicContentSize() }
|
|
}
|
|
}
|
|
|
|
override var image: NSImage? {
|
|
get { nil }
|
|
set {
|
|
super.image = nil
|
|
super.alternateImage = nil
|
|
self.iconView.image = newValue
|
|
if !self.isConfiguring { self.invalidateIntrinsicContentSize() }
|
|
}
|
|
}
|
|
|
|
func setContentTintColor(_ color: NSColor?) {
|
|
self.iconView.contentTintColor = color
|
|
self.titleField.textColor = color
|
|
}
|
|
|
|
func setTitleFontSize(_ size: CGFloat) {
|
|
self.titleField.font = NSFont.systemFont(ofSize: size)
|
|
}
|
|
|
|
func setAllowsTwoLineTitle(_ allow: Bool) {
|
|
let hasWhitespace = self.titleField.stringValue.rangeOfCharacter(from: .whitespacesAndNewlines) != nil
|
|
let shouldWrap = allow && hasWhitespace
|
|
self.titleField.maximumNumberOfLines = shouldWrap ? 2 : 1
|
|
self.titleField.usesSingleLineMode = !shouldWrap
|
|
self.titleField.lineBreakMode = shouldWrap ? .byWordWrapping : .byTruncatingTail
|
|
}
|
|
|
|
override var intrinsicContentSize: NSSize {
|
|
let size = self.stack.fittingSize
|
|
return NSSize(
|
|
width: size.width + self.contentPadding.left + self.contentPadding.right,
|
|
height: size.height + self.contentPadding.top + self.contentPadding.bottom)
|
|
}
|
|
|
|
init(title: String, image: NSImage, target: AnyObject?, action: Selector?) {
|
|
super.init(frame: .zero)
|
|
self.target = target
|
|
self.action = action
|
|
self.isConfiguring = true // Batch invalidations during setup
|
|
self.configure()
|
|
self.title = title
|
|
self.image = image
|
|
self.isConfiguring = false
|
|
self.invalidateIntrinsicContentSize() // Single invalidation after setup
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
nil
|
|
}
|
|
|
|
private func configure() {
|
|
self.bezelStyle = .regularSquare
|
|
self.isBordered = false
|
|
self.setButtonType(.toggle)
|
|
self.controlSize = .small
|
|
self.wantsLayer = true
|
|
self.setAccessibilityRole(.button)
|
|
|
|
self.iconView.imageScaling = .scaleNone
|
|
self.iconView.translatesAutoresizingMaskIntoConstraints = false
|
|
self.titleField.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
|
|
self.titleField.alignment = .left
|
|
self.titleField.lineBreakMode = .byTruncatingTail
|
|
self.titleField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
self.titleField.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
self.setContentTintColor(NSColor.secondaryLabelColor)
|
|
|
|
self.stack.orientation = .horizontal
|
|
self.stack.alignment = .centerY
|
|
self.stack.spacing = 1
|
|
self.stack.translatesAutoresizingMaskIntoConstraints = false
|
|
self.stack.addArrangedSubview(self.iconView)
|
|
self.stack.addArrangedSubview(self.titleField)
|
|
self.addSubview(self.stack)
|
|
|
|
let iconWidth = self.iconView.widthAnchor.constraint(equalToConstant: 16)
|
|
let iconHeight = self.iconView.heightAnchor.constraint(equalToConstant: 16)
|
|
self.iconSizeConstraints = [iconWidth, iconHeight]
|
|
|
|
let top = self.stack.topAnchor.constraint(
|
|
greaterThanOrEqualTo: self.topAnchor,
|
|
constant: self.contentPadding.top)
|
|
let leading = self.stack.leadingAnchor.constraint(
|
|
greaterThanOrEqualTo: self.leadingAnchor,
|
|
constant: self.contentPadding.left)
|
|
let trailing = self.stack.trailingAnchor.constraint(
|
|
lessThanOrEqualTo: self.trailingAnchor,
|
|
constant: -self.contentPadding.right)
|
|
let centerX = self.stack.centerXAnchor.constraint(equalTo: self.centerXAnchor)
|
|
centerX.priority = .defaultHigh
|
|
let centerY = self.stack.centerYAnchor.constraint(equalTo: self.centerYAnchor)
|
|
let bottom = self.stack.bottomAnchor.constraint(
|
|
lessThanOrEqualTo: self.bottomAnchor,
|
|
constant: -self.contentPadding.bottom)
|
|
self.paddingConstraints = [top, leading, trailing, bottom, centerX, centerY]
|
|
|
|
NSLayoutConstraint.activate(self.paddingConstraints + self.iconSizeConstraints)
|
|
}
|
|
}
|
|
|
|
final class StackedToggleButton: NSButton {
|
|
private let iconView = NSImageView()
|
|
private let titleField = NSTextField(labelWithString: "")
|
|
private let stack = NSStackView()
|
|
private var paddingConstraints: [NSLayoutConstraint] = []
|
|
private var iconSizeConstraints: [NSLayoutConstraint] = []
|
|
private var isConfiguring = false // Batch invalidation during setup
|
|
|
|
var contentPadding = NSEdgeInsets(top: 2, left: 4, bottom: 2, right: 4) {
|
|
didSet {
|
|
self.paddingConstraints.first { $0.firstAttribute == .top }?.constant = self.contentPadding.top
|
|
self.paddingConstraints.first { $0.firstAttribute == .leading }?.constant = self.contentPadding.left
|
|
self.paddingConstraints.first { $0.firstAttribute == .trailing }?.constant = -self.contentPadding.right
|
|
self.paddingConstraints.first { $0.firstAttribute == .bottom }?.constant = -self.contentPadding.bottom
|
|
if !self.isConfiguring { self.invalidateIntrinsicContentSize() }
|
|
}
|
|
}
|
|
|
|
override var title: String {
|
|
get { "" }
|
|
set {
|
|
super.title = ""
|
|
super.alternateTitle = ""
|
|
super.attributedTitle = NSAttributedString(string: "")
|
|
super.attributedAlternateTitle = NSAttributedString(string: "")
|
|
self.titleField.stringValue = newValue
|
|
self.setAccessibilityLabel(newValue)
|
|
if !self.isConfiguring { self.invalidateIntrinsicContentSize() }
|
|
}
|
|
}
|
|
|
|
override var image: NSImage? {
|
|
get { nil }
|
|
set {
|
|
super.image = nil
|
|
super.alternateImage = nil
|
|
self.iconView.image = newValue
|
|
if !self.isConfiguring { self.invalidateIntrinsicContentSize() }
|
|
}
|
|
}
|
|
|
|
func setContentTintColor(_ color: NSColor?) {
|
|
self.iconView.contentTintColor = color
|
|
self.titleField.textColor = color
|
|
}
|
|
|
|
func setTitleFontSize(_ size: CGFloat) {
|
|
self.titleField.font = NSFont.systemFont(ofSize: size)
|
|
}
|
|
|
|
func setAllowsTwoLineTitle(_ allow: Bool) {
|
|
let hasWhitespace = self.titleField.stringValue.rangeOfCharacter(from: .whitespacesAndNewlines) != nil
|
|
let shouldWrap = allow && hasWhitespace
|
|
self.titleField.maximumNumberOfLines = shouldWrap ? 2 : 1
|
|
self.titleField.usesSingleLineMode = !shouldWrap
|
|
self.titleField.lineBreakMode = shouldWrap ? .byWordWrapping : .byTruncatingTail
|
|
}
|
|
|
|
override var intrinsicContentSize: NSSize {
|
|
let size = self.stack.fittingSize
|
|
return NSSize(
|
|
width: size.width + self.contentPadding.left + self.contentPadding.right,
|
|
height: size.height + self.contentPadding.top + self.contentPadding.bottom)
|
|
}
|
|
|
|
init(title: String, image: NSImage, target: AnyObject?, action: Selector?) {
|
|
super.init(frame: .zero)
|
|
self.target = target
|
|
self.action = action
|
|
self.isConfiguring = true // Batch invalidations during setup
|
|
self.configure()
|
|
self.title = title
|
|
self.image = image
|
|
self.isConfiguring = false
|
|
self.invalidateIntrinsicContentSize() // Single invalidation after setup
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
nil
|
|
}
|
|
|
|
private func configure() {
|
|
self.bezelStyle = .regularSquare
|
|
self.isBordered = false
|
|
self.setButtonType(.toggle)
|
|
self.controlSize = .small
|
|
self.wantsLayer = true
|
|
self.setAccessibilityRole(.button)
|
|
|
|
self.iconView.imageScaling = .scaleNone
|
|
self.iconView.translatesAutoresizingMaskIntoConstraints = false
|
|
self.titleField.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize - 2)
|
|
self.titleField.alignment = .center
|
|
self.titleField.lineBreakMode = .byTruncatingTail
|
|
self.titleField.maximumNumberOfLines = 1
|
|
self.titleField.usesSingleLineMode = true
|
|
self.setContentTintColor(NSColor.secondaryLabelColor)
|
|
|
|
self.stack.orientation = .vertical
|
|
self.stack.alignment = .centerX
|
|
self.stack.spacing = 0
|
|
self.stack.translatesAutoresizingMaskIntoConstraints = false
|
|
self.stack.addArrangedSubview(self.iconView)
|
|
self.stack.addArrangedSubview(self.titleField)
|
|
self.addSubview(self.stack)
|
|
|
|
let iconWidth = self.iconView.widthAnchor.constraint(equalToConstant: 16)
|
|
let iconHeight = self.iconView.heightAnchor.constraint(equalToConstant: 16)
|
|
self.iconSizeConstraints = [iconWidth, iconHeight]
|
|
|
|
// Force an even layout width (button width minus padding) so the icon doesn't land on 0.5pt centers.
|
|
let top = self.stack.topAnchor.constraint(
|
|
greaterThanOrEqualTo: self.topAnchor,
|
|
constant: self.contentPadding.top)
|
|
let leading = self.stack.leadingAnchor.constraint(
|
|
equalTo: self.leadingAnchor,
|
|
constant: self.contentPadding.left)
|
|
let trailing = self.stack.trailingAnchor.constraint(
|
|
equalTo: self.trailingAnchor,
|
|
constant: -self.contentPadding.right)
|
|
let bottom = self.stack.bottomAnchor.constraint(
|
|
lessThanOrEqualTo: self.bottomAnchor,
|
|
constant: -self.contentPadding.bottom)
|
|
let centerY = self.stack.centerYAnchor.constraint(equalTo: self.centerYAnchor)
|
|
self.paddingConstraints = [top, leading, trailing, bottom, centerY]
|
|
|
|
NSLayoutConstraint.activate(self.paddingConstraints + self.iconSizeConstraints)
|
|
}
|
|
}
|