项目文件夹

文件
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

548 行
18 KiB
Swift

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