172 lines
6.4 KiB
Swift
172 lines
6.4 KiB
Swift
import AppKit
|
|
|
|
/// Borderless NSWindow subclass that can become key, allowing text field input.
|
|
class KeyableWindow: NSWindow {
|
|
override var canBecomeKey: Bool { true }
|
|
override var canBecomeMain: Bool { true }
|
|
}
|
|
|
|
extension AppDelegate {
|
|
|
|
func setupWindows() {
|
|
for screen in NSScreen.screens {
|
|
let window = KeyableWindow(
|
|
contentRect: screen.frame,
|
|
styleMask: .borderless,
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
window.level = .screenSaver
|
|
window.backgroundColor = .clear
|
|
window.isOpaque = false
|
|
window.hasShadow = false
|
|
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
|
window.ignoresMouseEvents = true
|
|
window.isReleasedWhenClosed = false
|
|
|
|
let gradientView = EdgeGradientView(frame: NSRect(origin: .zero, size: screen.frame.size))
|
|
gradientView.autoresizingMask = [.width, .height]
|
|
window.contentView = gradientView
|
|
|
|
window.alphaValue = 0.0
|
|
window.orderFrontRegardless()
|
|
overlayWindows.append(window)
|
|
|
|
let pillW: CGFloat = 200
|
|
let pillH: CGFloat = 80
|
|
let pillX = screen.frame.origin.x + (screen.frame.width - pillW) / 2
|
|
let pillY = screen.frame.origin.y + 40
|
|
|
|
let pillWindow = NSWindow(
|
|
contentRect: NSRect(x: pillX, y: pillY, width: pillW, height: pillH),
|
|
styleMask: .borderless,
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
pillWindow.level = .screenSaver
|
|
pillWindow.backgroundColor = .clear
|
|
pillWindow.isOpaque = false
|
|
pillWindow.hasShadow = false
|
|
pillWindow.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
|
pillWindow.ignoresMouseEvents = true
|
|
pillWindow.isReleasedWhenClosed = false
|
|
|
|
let pillView = NSView(frame: NSRect(x: 0, y: 0, width: pillW, height: pillH))
|
|
pillView.wantsLayer = true
|
|
pillView.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.6).cgColor
|
|
pillView.layer?.cornerRadius = pillH / 2
|
|
|
|
let label = NSTextField(labelWithString: "00:10")
|
|
label.font = NSFont.monospacedSystemFont(ofSize: 36, weight: .bold)
|
|
label.textColor = Settings.shared.gradientColor
|
|
label.alignment = .center
|
|
label.frame = NSRect(x: 0, y: 14, width: pillW, height: 50)
|
|
pillView.addSubview(label)
|
|
|
|
pillWindow.contentView = pillView
|
|
pillWindow.alphaValue = 0.0
|
|
pillWindow.orderFrontRegardless()
|
|
|
|
countdownWindows.append(pillWindow)
|
|
countdownLabels.append(label)
|
|
}
|
|
}
|
|
|
|
func showCountdownPills() {
|
|
for window in countdownWindows {
|
|
if window.alphaValue < 1.0 {
|
|
NSAnimationContext.runAnimationGroup { ctx in
|
|
ctx.duration = 0.2
|
|
window.animator().alphaValue = 1.0
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func hideCountdownPills() {
|
|
for window in countdownWindows { window.alphaValue = 0.0 }
|
|
}
|
|
|
|
func updateCountdownLabels() {
|
|
let seconds = max(remainingSeconds, 0)
|
|
let text = String(format: "00:%02d", seconds)
|
|
for label in countdownLabels { label.stringValue = text }
|
|
}
|
|
|
|
/// Single entry point for pulse schedule. Used by both work countdown and break.
|
|
func applyPulseSchedule(remainingSeconds: Int, totalSeconds: Int) {
|
|
if CountdownPulseSchedule.shouldBeSolid(remainingSeconds: remainingSeconds) {
|
|
if !isSolid { makeSolid() }
|
|
} else if CountdownPulseSchedule.shouldBlink(remainingSeconds: remainingSeconds) {
|
|
triggerBlink(remainingSeconds: remainingSeconds, totalSeconds: totalSeconds)
|
|
}
|
|
}
|
|
|
|
func triggerBlink(remainingSeconds: Int, totalSeconds: Int) {
|
|
let settings = Settings.shared
|
|
let scale = CGFloat(settings.intensityScale)
|
|
let durationScale = settings.blinkDurationScale
|
|
|
|
let countdownElapsed = Double(totalSeconds - remainingSeconds)
|
|
let countdownTotal = Double(totalSeconds)
|
|
let progress = min(max(countdownElapsed / countdownTotal, 0.0), 1.0)
|
|
let baseIntensity = 0.15 + (0.60 * progress) // 0.15 at start, up to 0.75 at 0:00
|
|
let intensity = min(baseIntensity * Double(scale), 1.0)
|
|
|
|
let d = CountdownPulseSchedule.blinkDurations(remainingSeconds: remainingSeconds, durationScale: durationScale)
|
|
let fadeIn = d.fadeIn
|
|
let hold = d.hold
|
|
let fadeOut = d.fadeOut
|
|
|
|
let color = settings.gradientColor
|
|
|
|
for window in overlayWindows {
|
|
guard let view = window.contentView as? EdgeGradientView else { continue }
|
|
view.intensity = CGFloat(intensity)
|
|
view.color = color
|
|
view.needsDisplay = true
|
|
|
|
NSAnimationContext.runAnimationGroup { ctx in
|
|
ctx.duration = fadeIn
|
|
window.animator().alphaValue = 1.0
|
|
} completionHandler: {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + hold) {
|
|
NSAnimationContext.runAnimationGroup({ ctx in
|
|
ctx.duration = fadeOut
|
|
window.animator().alphaValue = 0.0
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func makeSolid() {
|
|
isSolid = true
|
|
let settings = Settings.shared
|
|
let maxIntensity = min(0.75 * CGFloat(settings.intensityScale), 1.0)
|
|
let color = settings.gradientColor
|
|
|
|
for window in overlayWindows {
|
|
guard let view = window.contentView as? EdgeGradientView else { continue }
|
|
view.intensity = maxIntensity
|
|
view.color = color
|
|
view.needsDisplay = true
|
|
NSAnimationContext.runAnimationGroup { ctx in
|
|
ctx.duration = 0.3
|
|
window.animator().alphaValue = 1.0
|
|
}
|
|
}
|
|
}
|
|
|
|
func resetOverlayToGradient() {
|
|
for window in overlayWindows {
|
|
window.ignoresMouseEvents = true
|
|
let size = window.frame.size
|
|
let gradientView = EdgeGradientView(frame: NSRect(origin: .zero, size: size))
|
|
gradientView.autoresizingMask = [.width, .height]
|
|
window.contentView = gradientView
|
|
window.alphaValue = 0.0
|
|
}
|
|
}
|
|
}
|