Files
Snaggle/GreenshotMac/Editor/StyleBarView.swift
T
2026-09-18 06:25:57 -05:00

210 lines
8.4 KiB
Swift

// StyleBarView.swift
// GreenshotMac
//
// The options row beneath the toolbar. It keeps a constant height whether or not the
// active tool has options, so selecting a tool never shifts the canvas. Colour lives
// in the tool rail on the left and is deliberately absent here.
import AppKit
@MainActor
protocol StyleBarDelegate: AnyObject {
func styleBar(_ bar: StyleBarView, didChange mutate: @escaping (inout ElementStyle) -> Void)
func styleBarDidApplyCrop(_ bar: StyleBarView)
}
@MainActor
final class StyleBarView: NSView {
/// Fixed height of the options row, occupied or not.
static let barHeight: CGFloat = 34
weak var delegate: StyleBarDelegate?
private let stack = NSStackView()
private let placeholder = NSTextField(labelWithString: "")
private let thicknessSlider = NSSlider()
private let thicknessLabel = NSTextField(labelWithString: "2 px")
private let fontPopUp = NSPopUpButton()
private let fontSizeField = NSTextField()
private let boldButton = NSButton()
private let italicButton = NSButton()
private let obfuscatePopUp = NSPopUpButton()
private let obfuscateSlider = NSSlider()
private let obfuscateLabel = NSTextField(labelWithString: "8")
private let shadowButton = NSButton()
private let cropButton = NSButton()
private var groups: [StyleCapabilities: [NSView]] = [:]
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
build()
}
required init?(coder: NSCoder) { fatalError("init(coder:) is not used") }
override var intrinsicContentSize: NSSize {
NSSize(width: NSView.noIntrinsicMetric, height: StyleBarView.barHeight)
}
private func build() {
translatesAutoresizingMaskIntoConstraints = false
heightAnchor.constraint(equalToConstant: StyleBarView.barHeight).isActive = true
stack.orientation = .horizontal
stack.alignment = .centerY
stack.spacing = 10
stack.edgeInsets = NSEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
stack.translatesAutoresizingMaskIntoConstraints = false
addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: leadingAnchor),
stack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor),
stack.centerYAnchor.constraint(equalTo: centerYAnchor)
])
placeholder.font = NSFont.systemFont(ofSize: 11)
placeholder.textColor = .tertiaryLabelColor
thicknessSlider.minValue = 0
thicknessSlider.maxValue = 20
thicknessSlider.target = self
thicknessSlider.action = #selector(thicknessChanged)
thicknessSlider.widthAnchor.constraint(equalToConstant: 100).isActive = true
thicknessLabel.font = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular)
groups[.lineThickness] = [label("Width"), thicknessSlider, thicknessLabel]
for family in ["Helvetica", "Helvetica Neue", "Menlo", "SF Pro", "Times New Roman", "Arial", "Georgia"] {
fontPopUp.addItem(withTitle: family)
}
fontPopUp.target = self
fontPopUp.action = #selector(fontChanged)
fontSizeField.target = self
fontSizeField.action = #selector(fontChanged)
fontSizeField.widthAnchor.constraint(equalToConstant: 46).isActive = true
configureToggle(boldButton, title: "B", action: #selector(fontChanged))
boldButton.font = NSFont.boldSystemFont(ofSize: 12)
configureToggle(italicButton, title: "I", action: #selector(fontChanged))
groups[.font] = [label("Font"), fontPopUp, fontSizeField, boldButton, italicButton]
obfuscatePopUp.addItems(withTitles: ObfuscateMode.allCases.map(\.title))
obfuscatePopUp.target = self
obfuscatePopUp.action = #selector(obfuscationChanged)
obfuscateSlider.minValue = 2
obfuscateSlider.maxValue = 40
obfuscateSlider.target = self
obfuscateSlider.action = #selector(obfuscationChanged)
obfuscateSlider.widthAnchor.constraint(equalToConstant: 100).isActive = true
obfuscateLabel.font = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular)
groups[.obfuscation] = [obfuscatePopUp, label("Strength"), obfuscateSlider, obfuscateLabel]
configureToggle(shadowButton, title: "Shadow", action: #selector(shadowChanged))
groups[.shadow] = [shadowButton]
cropButton.title = "Apply crop"
cropButton.bezelStyle = .rounded
cropButton.target = self
cropButton.action = #selector(applyCrop)
}
private func label(_ text: String) -> NSTextField {
let field = NSTextField(labelWithString: text)
field.font = NSFont.systemFont(ofSize: 11)
field.textColor = .secondaryLabelColor
return field
}
private func configureToggle(_ button: NSButton, title: String, action: Selector) {
button.title = title
button.setButtonType(.pushOnPushOff)
button.bezelStyle = .rounded
button.target = self
button.action = action
}
/// Rebuilds the row for the current tool or selection. The row itself never disappears.
func update(capabilities: StyleCapabilities, style: ElementStyle, showCrop: Bool, toolName: String) {
for view in stack.arrangedSubviews {
stack.removeArrangedSubview(view)
view.removeFromSuperview()
}
thicknessSlider.doubleValue = Double(style.lineThickness)
thicknessLabel.stringValue = "\(Int(style.lineThickness)) px"
fontPopUp.selectItem(withTitle: style.fontFamily)
fontSizeField.stringValue = String(Int(style.fontSize))
boldButton.state = style.bold ? .on : .off
italicButton.state = style.italic ? .on : .off
obfuscatePopUp.selectItem(withTitle: style.obfuscateMode.title)
let strength = style.obfuscateMode == .pixelate ? style.pixelSize : style.blurRadius
obfuscateSlider.doubleValue = Double(strength)
obfuscateLabel.stringValue = "\(Int(strength))"
shadowButton.state = style.dropShadow ? .on : .off
// Colour is intentionally not offered here; the tool rail owns it.
for capability in [StyleCapabilities.lineThickness, .font, .obfuscation, .shadow] {
guard capabilities.contains(capability), let views = groups[capability] else { continue }
for view in views { stack.addArrangedSubview(view) }
}
if showCrop {
stack.addArrangedSubview(cropButton)
}
if stack.arrangedSubviews.isEmpty {
placeholder.stringValue = "\(toolName) — no options"
stack.addArrangedSubview(placeholder)
}
}
// MARK: - Actions
@objc private func thicknessChanged() {
let thickness = CGFloat(thicknessSlider.doubleValue.rounded())
thicknessLabel.stringValue = "\(Int(thickness)) px"
Configuration.shared.editorLineThickness = Int(thickness)
delegate?.styleBar(self) { $0.lineThickness = thickness }
}
@objc private func fontChanged() {
let family = fontPopUp.titleOfSelectedItem ?? "Helvetica"
let size = CGFloat(max(6, Int(fontSizeField.stringValue) ?? 14))
let bold = boldButton.state == .on
let italic = italicButton.state == .on
Configuration.shared.editorFontFamily = family
Configuration.shared.editorFontSize = Int(size)
delegate?.styleBar(self) { style in
style.fontFamily = family
style.fontSize = size
style.bold = bold
style.italic = italic
}
}
@objc private func obfuscationChanged() {
let mode = ObfuscateMode.allCases[max(0, obfuscatePopUp.indexOfSelectedItem)]
let value = CGFloat(obfuscateSlider.doubleValue.rounded())
obfuscateLabel.stringValue = "\(Int(value))"
if mode == .pixelate {
Configuration.shared.editorPixelSize = Int(value)
} else {
Configuration.shared.editorBlurRadius = Int(value)
}
delegate?.styleBar(self) { style in
style.obfuscateMode = mode
if mode == .pixelate { style.pixelSize = value } else { style.blurRadius = value }
}
}
@objc private func shadowChanged() {
let enabled = shadowButton.state == .on
Configuration.shared.editorDropShadow = enabled
delegate?.styleBar(self) { $0.dropShadow = enabled }
}
@objc private func applyCrop() {
delegate?.styleBarDidApplyCrop(self)
}
}