357 lines
15 KiB
Swift
357 lines
15 KiB
Swift
// Copyright (C) 2026 ysicing
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import AppKit
|
|
import Localization
|
|
import SwiftUI
|
|
|
|
@MainActor
|
|
final class RestPanel: NSPanel {
|
|
var onEscape: (() -> Void)?
|
|
var hudDragEnabled = false
|
|
private var dragStartScreen: NSPoint?
|
|
private var dragStartOrigin: NSPoint?
|
|
|
|
override var canBecomeKey: Bool { true }
|
|
override func cancelOperation(_ sender: Any?) { onEscape?() }
|
|
override func keyDown(with event: NSEvent) {
|
|
if event.keyCode == 53 { onEscape?() } else { super.keyDown(with: event) }
|
|
}
|
|
|
|
override func sendEvent(_ event: NSEvent) {
|
|
if hudDragEnabled {
|
|
switch event.type {
|
|
case .leftMouseDown where isHUDDragRegion(event.locationInWindow):
|
|
dragStartScreen = convertPoint(toScreen: event.locationInWindow)
|
|
dragStartOrigin = frame.origin
|
|
return
|
|
case .leftMouseDragged:
|
|
if let dragStartScreen, let dragStartOrigin {
|
|
let current = convertPoint(toScreen: event.locationInWindow)
|
|
var origin = NSPoint(x: dragStartOrigin.x + current.x - dragStartScreen.x,
|
|
y: dragStartOrigin.y + current.y - dragStartScreen.y)
|
|
if let screen = NSScreen.screens.first(where: { $0.frame.contains(current) }) ?? self.screen ?? NSScreen.main {
|
|
let visible = screen.visibleFrame
|
|
origin.x = min(max(origin.x, visible.minX), visible.maxX - frame.width)
|
|
origin.y = min(max(origin.y, visible.minY), visible.maxY - frame.height)
|
|
}
|
|
setFrameOrigin(origin)
|
|
return
|
|
}
|
|
case .leftMouseUp where dragStartScreen != nil:
|
|
dragStartScreen = nil
|
|
dragStartOrigin = nil
|
|
return
|
|
default: break
|
|
}
|
|
}
|
|
super.sendEvent(event)
|
|
}
|
|
|
|
/// 下方控制按钮与顶部两个操作按钮保留正常点击,其余区域都能直接拖动。
|
|
private func isHUDDragRegion(_ point: NSPoint) -> Bool {
|
|
guard point.y > 44 else { return false }
|
|
let inHeader = point.y >= frame.height - 42
|
|
let overActionButtons = L10n.language.isRightToLeft ? point.x < 60 : point.x > frame.width - 60
|
|
return !inHeader || !overActionButtons
|
|
}
|
|
}
|
|
|
|
private struct RestOverlayView: View {
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
|
|
let rest: RestController
|
|
@State private var breathing = false
|
|
@State private var holding = false
|
|
|
|
var body: some View {
|
|
let language = rest.settings.language.resolved
|
|
let accent = Color(red: 0.32, green: 0.89, blue: 0.53)
|
|
ZStack {
|
|
if reduceTransparency {
|
|
Color(nsColor: .windowBackgroundColor)
|
|
} else {
|
|
Color.black.opacity(0.25)
|
|
}
|
|
VStack(spacing: 22) {
|
|
ZStack {
|
|
Circle().fill(accent.opacity(0.20))
|
|
.frame(width: 195, height: 195)
|
|
.blur(radius: 28)
|
|
.scaleEffect(breathing && !reduceMotion ? 1.12 : 0.9)
|
|
Circle().stroke(accent.opacity(0.22), lineWidth: 2)
|
|
Circle()
|
|
.trim(from: 0, to: rest.phaseDuration > 0
|
|
? min(1, max(0, 1 - rest.secondsRemaining / rest.phaseDuration)) : 0)
|
|
.stroke(accent, style: StrokeStyle(lineWidth: 3, lineCap: .round))
|
|
.rotationEffect(.degrees(-90))
|
|
VStack(spacing: 8) {
|
|
Text(rest.phase.title).font(.system(size: 14, weight: .semibold))
|
|
Text(formatted(rest.secondsRemaining))
|
|
.font(.system(size: 52, weight: .light, design: .rounded))
|
|
.monospacedDigit()
|
|
}
|
|
.foregroundStyle(accent)
|
|
}
|
|
.frame(width: 250, height: 250)
|
|
Text(tr(rest.settings.restMode == .workday ? "起身活动一下" : "让眼睛休息一下"))
|
|
.font(.system(size: 34, weight: .medium, design: .rounded))
|
|
.tracking(-1)
|
|
Text(tr(rest.settings.restMode == .workday
|
|
? "走动几步,看看远处,让身体和眼睛都休息"
|
|
: "看看远处,轻轻眨眼,放松肩颈"))
|
|
.font(.system(size: 16))
|
|
.foregroundStyle(.secondary)
|
|
HStack(spacing: 14) {
|
|
Text(tr("长按跳过 · 或按 Esc 随时返回"))
|
|
.font(.system(size: 13, weight: .medium))
|
|
.padding(.horizontal, 22)
|
|
.padding(.vertical, 12)
|
|
.background(.regularMaterial, in: Capsule())
|
|
.scaleEffect(holding ? 0.96 : 1)
|
|
.onLongPressGesture(minimumDuration: 0.8, pressing: { holding = $0 }) { rest.skip() }
|
|
.accessibilityAddTraits(.isButton)
|
|
.accessibilityAction { rest.skip() }
|
|
Button(tr("再专注 5 分钟")) { rest.postponeFiveMinutes() }
|
|
.buttonStyle(.plain)
|
|
.font(.system(size: 13, weight: .medium))
|
|
.padding(.horizontal, 22)
|
|
.padding(.vertical, 12)
|
|
.background(.regularMaterial, in: Capsule())
|
|
}
|
|
}
|
|
.frame(maxWidth: 680)
|
|
}
|
|
.ignoresSafeArea()
|
|
.environment(\.locale, L10n.locale(for: language))
|
|
.environment(\.layoutDirection, language.isRightToLeft ? .rightToLeft : .leftToRight)
|
|
.onAppear {
|
|
guard !reduceMotion else { return }
|
|
withAnimation(.easeInOut(duration: 3.5).repeatForever(autoreverses: true)) { breathing = true }
|
|
}
|
|
}
|
|
|
|
private func formatted(_ seconds: TimeInterval) -> String {
|
|
let total = max(0, Int(seconds.rounded(.up)))
|
|
return String(format: "%02d:%02d", total / 60, total % 60)
|
|
}
|
|
}
|
|
|
|
private struct RestHUDView: View {
|
|
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
|
|
let rest: RestController
|
|
let close: () -> Void
|
|
|
|
var body: some View {
|
|
let language = rest.settings.language.resolved
|
|
VStack(spacing: 8) {
|
|
HStack(spacing: 8) {
|
|
Label(rest.phase.title, systemImage: rest.phase.isResting ? "eye" : "timer")
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundStyle(rest.phase.isResting ? .green : .orange)
|
|
Spacer(minLength: 0)
|
|
Image(systemName: "line.3.horizontal")
|
|
.font(.system(size: 10))
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 36, height: 20)
|
|
.help(tr("拖动迷你 HUD"))
|
|
Button {
|
|
rest.settings.restHUDStyle = rest.settings.restHUDStyle.next
|
|
} label: {
|
|
Image(systemName: rest.settings.restHUDStyle.symbol)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help(tr("切换迷你 HUD 样式"))
|
|
.accessibilityLabel(tr("切换迷你 HUD 样式"))
|
|
Button(action: close) { Image(systemName: "xmark") }
|
|
.buttonStyle(.plain)
|
|
.help(tr("收起迷你 HUD"))
|
|
}
|
|
display
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 76)
|
|
HStack(spacing: 20) {
|
|
Button { rest.startPause() } label: {
|
|
Image(systemName: rest.isRunning ? "pause.fill" : "play.fill")
|
|
}
|
|
.help(tr(rest.isRunning ? "暂停"
|
|
: rest.settings.restMode == .workday && !rest.isWorkdayActive ? "开始工作"
|
|
: rest.canContinue || rest.isWorkdayActive ? "继续" : "开始"))
|
|
Button { rest.resetCurrentPhase() } label: { Image(systemName: "arrow.counterclockwise") }
|
|
.help(tr("重置"))
|
|
.disabled(rest.settings.restMode == .workday && !rest.isWorkdayActive)
|
|
Button { rest.skip() } label: { Image(systemName: "forward.end.fill") }
|
|
.help(tr("跳过"))
|
|
.disabled(rest.settings.restMode == .workday && !rest.isWorkdayActive)
|
|
if rest.settings.restMode == .workday && rest.isWorkdayActive {
|
|
Button { rest.endWorkday() } label: { Image(systemName: "stop.fill") }
|
|
.help(tr("结束工作"))
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.padding(14)
|
|
.frame(width: 250, height: 160)
|
|
.background {
|
|
if reduceTransparency {
|
|
RoundedRectangle(cornerRadius: 18).fill(Color(nsColor: .windowBackgroundColor))
|
|
} else {
|
|
RoundedRectangle(cornerRadius: 18).fill(.regularMaterial)
|
|
}
|
|
}
|
|
.environment(\.locale, L10n.locale(for: language))
|
|
.environment(\.layoutDirection, language.isRightToLeft ? .rightToLeft : .leftToRight)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var display: some View {
|
|
let progress = rest.phaseDuration > 0
|
|
? min(1, max(0, 1 - rest.secondsRemaining / rest.phaseDuration)) : 0
|
|
let tint: Color = rest.phase.isResting ? .green : .orange
|
|
let clock = String(format: "%02d:%02d", max(0, Int(rest.secondsRemaining.rounded(.up))) / 60,
|
|
max(0, Int(rest.secondsRemaining.rounded(.up))) % 60)
|
|
|
|
switch rest.settings.restHUDStyle {
|
|
case .countdown:
|
|
Text(clock)
|
|
.font(.system(size: 30, weight: .medium, design: .rounded))
|
|
.monospacedDigit()
|
|
case .ring:
|
|
ZStack {
|
|
Circle().stroke(tint.opacity(0.18), lineWidth: 5)
|
|
Circle().trim(from: 0, to: progress)
|
|
.stroke(tint, style: StrokeStyle(lineWidth: 5, lineCap: .round))
|
|
.rotationEffect(.degrees(-90))
|
|
Text(clock).font(.system(size: 14, weight: .semibold, design: .rounded)).monospacedDigit()
|
|
}
|
|
.frame(width: 72, height: 72)
|
|
case .hourglass:
|
|
VStack(spacing: 8) {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: "hourglass")
|
|
.font(.system(size: 42, weight: .ultraLight))
|
|
.symbolRenderingMode(.hierarchical)
|
|
.foregroundStyle(tint)
|
|
.accessibilityHidden(true)
|
|
Text(clock)
|
|
.font(.system(size: 19, weight: .medium, design: .rounded))
|
|
.monospacedDigit()
|
|
}
|
|
GeometryReader { geometry in
|
|
ZStack(alignment: .leading) {
|
|
Capsule().fill(tint.opacity(0.14))
|
|
Capsule().fill(tint)
|
|
.frame(width: geometry.size.width * (1 - progress))
|
|
}
|
|
}
|
|
.frame(width: 142, height: 4)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class RestWindowController {
|
|
private let rest: RestController
|
|
private var overlays: [RestPanel] = []
|
|
private var hud: RestPanel?
|
|
private var screenObserver: NSObjectProtocol?
|
|
|
|
init(rest: RestController) {
|
|
self.rest = rest
|
|
screenObserver = NotificationCenter.default.addObserver(forName: NSApplication.didChangeScreenParametersNotification,
|
|
object: nil, queue: .main) { [weak self] _ in
|
|
MainActor.assumeIsolated { self?.refreshScreens() }
|
|
}
|
|
}
|
|
|
|
func showRest() {
|
|
hideRest()
|
|
for screen in NSScreen.screens {
|
|
let panel = RestPanel(contentRect: screen.frame, styleMask: [.borderless, .nonactivatingPanel],
|
|
backing: .buffered, defer: false, screen: screen)
|
|
panel.level = .floating
|
|
// NSPanel 默认在应用失去前台时隐藏;幕布必须一直可见,状态才与界面一致。
|
|
panel.hidesOnDeactivate = false
|
|
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
|
panel.isOpaque = false
|
|
panel.backgroundColor = .clear
|
|
panel.appearance = NSAppearance(named: .darkAqua)
|
|
panel.hasShadow = false
|
|
panel.onEscape = { [weak rest] in rest?.skip() }
|
|
let blur = NSVisualEffectView(frame: NSRect(origin: .zero, size: screen.frame.size))
|
|
blur.blendingMode = .behindWindow
|
|
blur.material = .underWindowBackground
|
|
blur.state = .active
|
|
let hosting = NSHostingView(rootView: RestOverlayView(rest: rest))
|
|
hosting.frame = blur.bounds
|
|
hosting.autoresizingMask = [.width, .height]
|
|
blur.addSubview(hosting)
|
|
panel.contentView = blur
|
|
panel.orderFrontRegardless()
|
|
overlays.append(panel)
|
|
}
|
|
overlays.first?.makeKey()
|
|
}
|
|
|
|
func ensureRestVisible() {
|
|
if overlays.isEmpty { showRest() }
|
|
}
|
|
|
|
func hideRest() {
|
|
overlays.forEach { $0.close() }
|
|
overlays.removeAll()
|
|
}
|
|
|
|
func toggleHUD() {
|
|
if hud?.isVisible == true { hideHUD(); return }
|
|
showHUD()
|
|
}
|
|
|
|
func showHUD() {
|
|
guard hud?.isVisible != true else { return }
|
|
let isNew = hud == nil
|
|
let panel = hud ?? RestPanel(contentRect: NSRect(x: 0, y: 0, width: 250, height: 160),
|
|
styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
|
|
hud = panel
|
|
panel.isReleasedWhenClosed = false
|
|
panel.level = .floating
|
|
panel.hidesOnDeactivate = false
|
|
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
|
panel.isOpaque = false
|
|
panel.backgroundColor = .clear
|
|
panel.hasShadow = true
|
|
panel.hudDragEnabled = true
|
|
panel.contentView = NSHostingView(rootView: RestHUDView(rest: rest) { [weak panel, weak rest] in
|
|
panel?.close()
|
|
rest?.setHUDVisible(false)
|
|
})
|
|
let visible = NSScreen.main?.visibleFrame ?? .zero
|
|
let defaultOrigin = NSPoint(x: visible.maxX - 266, y: visible.midY - 80)
|
|
if isNew {
|
|
panel.setFrameAutosaveName("XStatsRestHUD")
|
|
if !panel.setFrameUsingName("XStatsRestHUD") { panel.setFrameOrigin(defaultOrigin) }
|
|
}
|
|
if !NSScreen.screens.contains(where: { $0.visibleFrame.intersects(panel.frame) }) {
|
|
panel.setFrameOrigin(defaultOrigin)
|
|
}
|
|
panel.orderFrontRegardless()
|
|
rest.setHUDVisible(true)
|
|
}
|
|
|
|
func hideHUD() {
|
|
hud?.close()
|
|
rest.setHUDVisible(false)
|
|
}
|
|
|
|
private func refreshScreens() {
|
|
if rest.phase.isResting && rest.isRunning { showRest() }
|
|
if let hud, hud.isVisible {
|
|
if !NSScreen.screens.contains(where: { $0.visibleFrame.intersects(hud.frame) }) {
|
|
let visible = NSScreen.main?.visibleFrame ?? .zero
|
|
hud.setFrameOrigin(NSPoint(x: visible.maxX - 266, y: visible.midY - 80))
|
|
}
|
|
}
|
|
}
|
|
}
|