Files
steipete--codexbar/Sources/CodexBar/ProviderSwitcherButtons.swift
T
2026-07-13 12:22:33 +08:00

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)
}
}