pommedoro/Sources/Pommedoro/SettingsMenu.swift

209 lines
7.3 KiB
Swift

import AppKit
// MARK: - Slider Menu Item View
class SliderMenuItemView: NSView {
let slider: NSSlider
let valueLabel: NSTextField
private let titleLabel: NSTextField
var onChanged: ((Double) -> Void)?
init(title: String, value: Double, minValue: Double, maxValue: Double, width: CGFloat = 280) {
titleLabel = NSTextField(labelWithString: title)
slider = NSSlider(value: value, minValue: minValue, maxValue: maxValue,
target: nil, action: nil)
valueLabel = NSTextField(labelWithString: String(format: "%.2fx", value))
super.init(frame: NSRect(x: 0, y: 0, width: width, height: 50))
titleLabel.font = NSFont.systemFont(ofSize: 12, weight: .medium)
titleLabel.textColor = .labelColor
titleLabel.frame = NSRect(x: 16, y: 28, width: width - 32, height: 16)
addSubview(titleLabel)
let sliderW = width - 80
slider.frame = NSRect(x: 16, y: 4, width: sliderW, height: 20)
slider.target = self
slider.action = #selector(sliderMoved)
slider.isContinuous = true
addSubview(slider)
valueLabel.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular)
valueLabel.textColor = .secondaryLabelColor
valueLabel.alignment = .right
valueLabel.frame = NSRect(x: sliderW + 20, y: 4, width: 44, height: 20)
addSubview(valueLabel)
}
required init?(coder: NSCoder) { fatalError() }
@objc private func sliderMoved() {
let v = slider.doubleValue
valueLabel.stringValue = String(format: "%.2fx", v)
onChanged?(v)
}
func update(value: Double) {
slider.doubleValue = value
valueLabel.stringValue = String(format: "%.2fx", value)
}
}
// MARK: - Color Swatch Menu Item View
class ColorSwatchMenuItemView: NSView {
private let titleLabel: NSTextField
private var swatchButtons: [NSButton] = []
private var selectedIndex: Int = -1
var onChanged: ((NSColor) -> Void)?
static let presetColors: [(String, NSColor)] = [
("Teal", .systemTeal),
("Blue", .systemBlue),
("Purple", .systemPurple),
("Pink", .systemPink),
("Red", .systemRed),
("Orange", .systemOrange),
("Yellow", .systemYellow),
("Green", .systemGreen),
("Indigo", .systemIndigo),
]
init(title: String, currentColor: NSColor, width: CGFloat = 280) {
titleLabel = NSTextField(labelWithString: title)
super.init(frame: NSRect(x: 0, y: 0, width: width, height: 56))
titleLabel.font = NSFont.systemFont(ofSize: 12, weight: .medium)
titleLabel.textColor = .labelColor
titleLabel.frame = NSRect(x: 16, y: 34, width: width - 32, height: 16)
addSubview(titleLabel)
let swatchSize: CGFloat = 22
let gap: CGFloat = 4
let count = Self.presetColors.count
let totalW = CGFloat(count) * swatchSize + CGFloat(count - 1) * gap
var x = (width - totalW) / 2
for (i, (_, color)) in Self.presetColors.enumerated() {
let btn = NSButton(frame: NSRect(x: x, y: 6, width: swatchSize, height: swatchSize))
btn.wantsLayer = true
btn.isBordered = false
btn.title = ""
btn.layer?.backgroundColor = color.cgColor
btn.layer?.cornerRadius = swatchSize / 2
btn.layer?.borderWidth = 2
btn.layer?.borderColor = NSColor.clear.cgColor
btn.tag = i
btn.target = self
btn.action = #selector(swatchClicked(_:))
addSubview(btn)
swatchButtons.append(btn)
x += swatchSize + gap
}
highlightClosest(to: currentColor)
}
required init?(coder: NSCoder) { fatalError() }
@objc private func swatchClicked(_ sender: NSButton) {
let idx = sender.tag
guard idx >= 0 && idx < Self.presetColors.count else { return }
setSelected(idx)
onChanged?(Self.presetColors[idx].1)
}
private func setSelected(_ index: Int) {
// Clear previous
for btn in swatchButtons {
btn.layer?.borderColor = NSColor.clear.cgColor
}
// Highlight new
if index >= 0 && index < swatchButtons.count {
swatchButtons[index].layer?.borderColor = NSColor.white.cgColor
selectedIndex = index
}
}
func highlightClosest(to target: NSColor) {
let t = target.usingColorSpace(.sRGB) ?? target
var bestIdx = 0
var bestDist: CGFloat = .greatestFiniteMagnitude
for (i, (_, color)) in Self.presetColors.enumerated() {
let c = color.usingColorSpace(.sRGB) ?? color
let dr = t.redComponent - c.redComponent
let dg = t.greenComponent - c.greenComponent
let db = t.blueComponent - c.blueComponent
let dist = dr*dr + dg*dg + db*db
if dist < bestDist { bestDist = dist; bestIdx = i }
}
setSelected(bestIdx)
}
func update(color: NSColor) {
highlightClosest(to: color)
}
}
// MARK: - Settings Menu Builder
class SettingsMenuBuilder {
private var intensityView: SliderMenuItemView?
private var durationView: SliderMenuItemView?
private var colorView: ColorSwatchMenuItemView?
func buildSettingsSubmenu() -> NSMenuItem {
let submenu = NSMenu(title: "Settings")
// Color swatches
let cv = ColorSwatchMenuItemView(title: "Gradient Color:", currentColor: Settings.shared.gradientColor)
cv.onChanged = { color in Settings.shared.gradientColor = color }
colorView = cv
let colorItem = NSMenuItem()
colorItem.view = cv
submenu.addItem(colorItem)
submenu.addItem(NSMenuItem.separator())
// Intensity
let iv = SliderMenuItemView(title: "Intensity", value: Settings.shared.intensityScale,
minValue: 0.25, maxValue: 2.0)
iv.onChanged = { val in Settings.shared.intensityScale = val }
intensityView = iv
let intensityItem = NSMenuItem()
intensityItem.view = iv
submenu.addItem(intensityItem)
submenu.addItem(NSMenuItem.separator())
// Blink Duration
let dv = SliderMenuItemView(title: "Blink Duration", value: Settings.shared.blinkDurationScale,
minValue: 0.25, maxValue: 3.0)
dv.onChanged = { val in Settings.shared.blinkDurationScale = val }
durationView = dv
let durationItem = NSMenuItem()
durationItem.view = dv
submenu.addItem(durationItem)
submenu.addItem(NSMenuItem.separator())
// Reset
let resetItem = NSMenuItem(title: "Reset to Defaults", action: #selector(resetDefaults), keyEquivalent: "")
resetItem.target = self
submenu.addItem(resetItem)
let parentItem = NSMenuItem(title: "Settings", action: nil, keyEquivalent: ",")
parentItem.submenu = submenu
return parentItem
}
func refreshValues() {
let s = Settings.shared
intensityView?.update(value: s.intensityScale)
durationView?.update(value: s.blinkDurationScale)
colorView?.update(color: s.gradientColor)
}
@objc private func resetDefaults() {
Settings.shared.resetToDefaults()
refreshValues()
}
}