Files
ggbond268--mactools/Plugins/Calendar/Sources/CalendarComponentView.swift
T
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

548 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import SwiftUI
import MacToolsPluginKit
struct CalendarComponentView: View {
private enum Layout {
static let contentPadding: CGFloat = 8
static let sectionSpacing: CGFloat = 3
static let gridSpacing: CGFloat = 6
static let headerHeight: CGFloat = 20
static let weekdayHeight: CGFloat = 10
static let dayCellSize: CGFloat = 36
static let cornerRadius: CGFloat = PluginComponentPanelLayoutMetrics.cardCornerRadius
}
@ObservedObject private var viewModel: CalendarComponentViewModel
private let localization: PluginLocalization
init(
context: PluginComponentContext,
viewModel: CalendarComponentViewModel,
localization: PluginLocalization = PluginLocalization(bundle: .main)
) {
self.viewModel = viewModel
self.localization = localization
}
var body: some View {
VStack(spacing: 0) {
calendarCard
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
private var calendarCard: some View {
VStack(spacing: Layout.sectionSpacing) {
CalendarHeaderView(
title: viewModel.month.title,
localization: localization,
onPrevious: { viewModel.moveMonth(by: -1) },
onToday: { viewModel.goToToday() },
onNext: { viewModel.moveMonth(by: 1) }
)
CalendarWeekdayRow(
symbols: viewModel.month.weekdaySymbols,
dayCellSize: Layout.dayCellSize,
gridSpacing: Layout.gridSpacing
)
CalendarMonthGrid(
days: viewModel.month.days,
selectedDayID: viewModel.selectedDay?.id,
dayCellSize: Layout.dayCellSize,
gridSpacing: Layout.gridSpacing,
localization: localization,
onSelect: { viewModel.select($0) },
onOpen: { viewModel.open($0) }
)
}
.padding(Layout.contentPadding)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background(CalendarComponentBackground(cornerRadius: Layout.cornerRadius))
}
}
private struct CalendarHeaderView: View {
let title: String
let localization: PluginLocalization
let onPrevious: () -> Void
let onToday: () -> Void
let onNext: () -> Void
var body: some View {
HStack(spacing: 6) {
Text(title)
.font(.system(size: 14, weight: .semibold, design: .rounded))
.frame(maxWidth: .infinity, alignment: .leading)
.lineLimit(1)
CalendarIconButton(
systemName: "chevron.left",
help: localization.string("header.previous.help", defaultValue: "上个月"),
action: onPrevious
)
Button(localization.string("header.today.button", defaultValue: "今天"), action: onToday)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.secondary)
.buttonStyle(.plain)
.frame(width: 32, height: 20)
.contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous))
.help(localization.string("header.today.help", defaultValue: "回到今天"))
CalendarIconButton(
systemName: "chevron.right",
help: localization.string("header.next.help", defaultValue: "下个月"),
action: onNext
)
}
.frame(height: 20)
}
}
private struct CalendarIconButton: View {
let systemName: String
let help: String
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: systemName)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.secondary)
.frame(width: 20, height: 20)
.contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous))
}
.buttonStyle(.plain)
.help(help)
}
}
private struct CalendarWeekdayRow: View {
let symbols: [String]
let dayCellSize: CGFloat
let gridSpacing: CGFloat
var body: some View {
HStack(spacing: gridSpacing) {
ForEach(Array(symbols.enumerated()), id: \.offset) { _, symbol in
Text(symbol)
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.secondary)
.frame(width: dayCellSize)
}
}
.frame(height: 10)
}
}
private struct CalendarMonthGrid: View {
let days: [CalendarDayModel]
let selectedDayID: String?
let dayCellSize: CGFloat
let gridSpacing: CGFloat
let localization: PluginLocalization
let onSelect: (CalendarDayModel) -> Void
let onOpen: (CalendarDayModel) -> Void
@State private var hoveredDayID: String?
private var columns: [GridItem] {
Array(
repeating: GridItem(.fixed(dayCellSize), spacing: gridSpacing),
count: 7
)
}
var body: some View {
LazyVGrid(columns: columns, spacing: gridSpacing) {
ForEach(days) { day in
CalendarDayCell(
day: day,
isSelected: selectedDayID == day.id,
localization: localization,
onOpen: { onOpen(day) }
)
.frame(width: dayCellSize, height: dayCellSize)
.background(
CalendarEventPopoverPresenter(
title: CalendarDayPresentation.dateTitle(for: day),
subtitle: CalendarDayPresentation.dateSubtitle(for: day, localization: localization),
events: day.events,
localization: localization,
isPresented: hoveredDayID == day.id && !day.events.isEmpty
)
)
.onHover { isHovered in
if isHovered {
hoveredDayID = day.id
onSelect(day)
} else if hoveredDayID == day.id {
hoveredDayID = nil
}
}
}
}
.frame(maxWidth: .infinity)
}
}
private struct CalendarDayCell: View {
let day: CalendarDayModel
let isSelected: Bool
let localization: PluginLocalization
let onOpen: () -> Void
var body: some View {
Button(action: onOpen) {
ZStack(alignment: .topTrailing) {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(backgroundColor)
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(borderColor, lineWidth: day.isToday ? 1.5 : 0)
VStack(spacing: 1) {
Text(day.dayNumber)
.font(.system(size: 13, weight: day.isToday ? .bold : .semibold, design: .rounded))
.foregroundStyle(primaryTextStyle)
.lineLimit(1)
if !day.lunarText.isEmpty {
Text(day.lunarText)
.font(.system(size: 8.5, weight: .medium))
.foregroundStyle(secondaryTextStyle)
.lineLimit(1)
.minimumScaleFactor(0.65)
}
CalendarEventDots(events: day.visibleEvents)
.padding(.top, 1)
}
.padding(.horizontal, 2)
.frame(maxWidth: .infinity, maxHeight: .infinity)
if let holidayKind = day.holidayKind {
CalendarHolidayBadge(kind: holidayKind, localization: localization)
.offset(x: 2, y: -3)
}
}
.contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
.buttonStyle(.plain)
.accessibilityLabel(accessibilityLabel)
}
private var backgroundColor: Color {
if isSelected {
return Color.accentColor.opacity(0.16)
}
if day.isToday {
return Color.accentColor.opacity(0.08)
}
return Color(nsColor: .controlBackgroundColor).opacity(day.isInDisplayedMonth ? 0.35 : 0.12)
}
private var borderColor: Color {
day.isToday ? Color.accentColor.opacity(0.95) : .clear
}
private var primaryTextStyle: HierarchicalShapeStyle {
if day.isInDisplayedMonth {
return day.isWeekend ? .secondary : .primary
}
return .tertiary
}
private var secondaryTextStyle: HierarchicalShapeStyle {
day.isInDisplayedMonth ? .secondary : .tertiary
}
private var accessibilityLabel: String {
var parts = [day.dayNumber]
if !day.lunarText.isEmpty {
parts.append(day.lunarText)
}
if day.isToday {
parts.append(localization.string("accessibility.today", defaultValue: "今天"))
}
if let holidayKind = day.holidayKind {
parts.append(CalendarDayPresentation.holidayText(for: holidayKind, localization: localization))
}
if !day.events.isEmpty {
parts.append(
localization.format(
"accessibility.eventCount",
defaultValue: "%d 个日程",
day.events.count
)
)
}
return parts.joined(
separator: localization.string("list.separator.comma", defaultValue: "")
)
}
}
private struct CalendarHolidayBadge: View {
let kind: CalendarHolidayKind
let localization: PluginLocalization
var body: some View {
Text(kind.badgeText(localization: localization))
.font(.system(size: 7, weight: .bold))
.foregroundStyle(.white)
.frame(width: 14, height: 14)
.background(
Circle()
.fill(kind == .holiday ? Color(nsColor: .systemTeal) : Color(nsColor: .systemOrange))
.shadow(color: .black.opacity(0.12), radius: 1, y: 0.5)
)
}
}
private struct CalendarEventDots: View {
private static let dotSize: CGFloat = 3
private static let rowHeight: CGFloat = 5
let events: [CalendarEventSummary]
var body: some View {
HStack(spacing: 2) {
if events.isEmpty {
Color.clear
.frame(width: Self.dotSize, height: Self.dotSize)
} else {
ForEach(events) { event in
Circle()
.fill(Color(calendarEventColor: event.color))
.frame(width: Self.dotSize, height: Self.dotSize)
}
}
}
.frame(height: Self.rowHeight)
}
}
private struct CalendarEventPopoverPresenter: NSViewRepresentable {
let title: String
let subtitle: String
let events: [CalendarEventSummary]
let localization: PluginLocalization
let isPresented: Bool
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeNSView(context: Context) -> NSView {
NSView(frame: .zero)
}
func updateNSView(_ nsView: NSView, context: Context) {
context.coordinator.update(
title: title,
subtitle: subtitle,
events: events,
localization: localization,
isPresented: isPresented,
sourceView: nsView
)
}
static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) {
coordinator.close()
}
final class Coordinator {
private var popover: NSPopover?
@MainActor
func update(
title: String,
subtitle: String,
events: [CalendarEventSummary],
localization: PluginLocalization,
isPresented: Bool,
sourceView: NSView
) {
guard isPresented, !events.isEmpty, sourceView.window != nil else {
close()
return
}
let popover = popover ?? makePopover()
let content = CalendarFloatingEventPopoverContent(
title: title,
subtitle: subtitle,
events: events,
localization: localization
)
let hostingController = NSHostingController(rootView: content)
CalendarAppearancePreference.stored().apply(to: hostingController.view)
popover.contentViewController = hostingController
popover.contentSize = NSSize(width: 230, height: min(hostingController.view.fittingSize.height, 260))
CalendarAppearancePreference.stored().apply(to: popover)
if !popover.isShown {
popover.show(relativeTo: sourceView.bounds, of: sourceView, preferredEdge: .maxY)
CalendarAppearancePreference.stored().apply(to: popover)
}
self.popover = popover
}
@MainActor
func close() {
popover?.performClose(nil)
popover = nil
}
@MainActor
private func makePopover() -> NSPopover {
let popover = NSPopover()
popover.behavior = .applicationDefined
popover.animates = false
return popover
}
}
}
private struct CalendarFloatingEventPopoverContent: View {
let title: String
let subtitle: String
let events: [CalendarEventSummary]
let localization: PluginLocalization
var body: some View {
VStack(alignment: .leading, spacing: 7) {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.system(size: 11, weight: .semibold))
.lineLimit(1)
Text(subtitle)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.secondary)
.lineLimit(1)
}
Divider()
VStack(alignment: .leading, spacing: 5) {
ForEach(Array(events.prefix(6))) { event in
CalendarEventRow(event: event)
}
if events.count > 6 {
Text(
localization.format(
"event.moreCount",
defaultValue: "还有 %d 个日程",
events.count - 6
)
)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.secondary)
}
}
}
.padding(10)
.frame(width: 230, alignment: .leading)
}
}
private enum CalendarDayPresentation {
static func dateTitle(for day: CalendarDayModel) -> String {
let formatter = DateFormatter()
formatter.locale = PluginRuntimeLocalization.locale
formatter.dateStyle = .medium
formatter.timeStyle = .none
return formatter.string(from: day.date)
}
static func dateSubtitle(
for day: CalendarDayModel,
localization: PluginLocalization = PluginLocalization(bundle: .main)
) -> String {
var parts: [String] = []
if !day.lunarText.isEmpty {
parts.append(day.lunarText)
}
if let holidayKind = day.holidayKind {
parts.append(holidayText(for: holidayKind, localization: localization))
} else if day.isWeekend {
parts.append(localization.string("day.weekend", defaultValue: "周末"))
}
if day.events.count > CalendarDayModel.maximumVisibleEvents {
parts.append(
localization.format(
"event.moreCount",
defaultValue: "还有 %d 个日程",
day.events.count - CalendarDayModel.maximumVisibleEvents
)
)
}
return parts.joined(separator: localization.string("list.separator.dot", defaultValue: " · "))
}
static func holidayText(
for holidayKind: CalendarHolidayKind,
localization: PluginLocalization = PluginLocalization(bundle: .main)
) -> String {
switch holidayKind {
case .holiday:
return localization.string("day.holiday", defaultValue: "休息日")
case .workday:
return localization.string("day.workday", defaultValue: "调休工作日")
}
}
}
private struct CalendarEventRow: View {
let event: CalendarEventSummary
var body: some View {
HStack(spacing: 6) {
Circle()
.fill(Color(calendarEventColor: event.color))
.frame(width: 6, height: 6)
Text(event.title)
.font(.system(size: 10.5, weight: .medium))
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
Text(event.timeText)
.font(.system(size: 9.5, weight: .medium, design: .rounded))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
private struct CalendarComponentBackground: View {
let cornerRadius: CGFloat
var body: some View {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.fill(Color.primary.opacity(0.045))
}
}
private extension Color {
init(calendarEventColor color: CalendarEventColor) {
self.init(
red: color.red,
green: color.green,
blue: color.blue,
opacity: color.alpha
)
}
}