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