diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift index 859cd6e..d2112ad 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift @@ -253,6 +253,9 @@ public final class AppController: NSObject, NSApplicationDelegate { Task { await model.keepAwake.setActive(!model.keepAwake.isActive) } case .purgeMemory: Task { await model.maintenance.run(.purgeMemory) } + case .openCalendar: + menuBar.dismissPopovers() + model.openCalendar() } } diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/HotKeyCenter.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/HotKeyCenter.swift index ae0e426..36ad06e 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/AppKit/HotKeyCenter.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/HotKeyCenter.swift @@ -4,7 +4,7 @@ import Localization /// 可以绑定全局快捷键的操作 public enum HotKeyAction: String, CaseIterable, Identifiable, Codable, Sendable { - case toggleMainWindow, showProcesses, toggleKeepAwake, purgeMemory + case toggleMainWindow, showProcesses, toggleKeepAwake, purgeMemory, openCalendar public var id: String { rawValue } @@ -14,6 +14,7 @@ public enum HotKeyAction: String, CaseIterable, Identifiable, Codable, Sendable case .showProcesses: tr("打开进程页") case .toggleKeepAwake: tr("开关防休眠") case .purgeMemory: tr("释放内存") + case .openCalendar: tr("打开日历") } } diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/StatusPanel.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/StatusPanel.swift index 725c7c6..26a9ac7 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/AppKit/StatusPanel.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/StatusPanel.swift @@ -22,11 +22,14 @@ final class StatusPanel: NSPanel { private let width: CGFloat /// 内容再少也不低于这个高度;合并模式的状态总览项目少时很矮,用更小的下限 private let minHeight: CGFloat + private let maxHeight: CGFloat? init(width: CGFloat, minHeight: CGFloat = DS.Size.panelMinHeight, + maxHeight: CGFloat? = nil, content: @escaping () -> Content, measuring: @escaping () -> Measuring) { self.width = width self.minHeight = minHeight + self.maxHeight = maxHeight makeMeasuringContent = { NSHostingView(rootView: measuring()) } super.init(contentRect: NSRect(x: 0, y: 0, width: width, height: minHeight), styleMask: [.borderless, .nonactivatingPanel], @@ -93,7 +96,7 @@ final class StatusPanel: NSPanel { /// 滚动内容比可视区域高出(或矮出)一截时调整窗口高度,顶边不动;屏幕放不下时停在最高处,由滚动条兜底 func adjustHeight(by overflow: CGFloat) { guard contentView != nil, abs(overflow) > 1 else { return } - let height = min(max(frame.height + overflow, minHeight), availableHeight()) + let height = min(max(frame.height + overflow, minHeight), availableHeight(), maxHeight ?? .greatestFiniteMagnitude) guard abs(height - frame.height) > 1 else { return } setFrame(NSRect(x: frame.minX, y: frame.maxY - height, width: frame.width, height: height), display: true) invalidateShadow() @@ -133,8 +136,9 @@ final class StatusPanel: NSPanel { private func targetFrame() -> NSRect { let visible = (anchorScreen ?? NSScreen.main)?.visibleFrame ?? .zero let natural = makeMeasuringContent().fittingSize.height + let preferredHeight = min(max(natural, minHeight), maxHeight ?? .greatestFiniteMagnitude) return Self.constrainedFrame(anchor: anchor, visible: visible, - preferredSize: NSSize(width: width, height: max(natural, minHeight))) + preferredSize: NSSize(width: width, height: preferredHeight)) } /// 显示器可用区始终优先于首选尺寸,窄屏和低分辨率下不能让最小高度把面板挤出屏幕。 diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAgenda.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAgenda.swift index 43b2957..06c8fce 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAgenda.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAgenda.swift @@ -96,7 +96,9 @@ private actor CalendarAgendaReader { isAllDay: components.hour == nil, isReminder: true) } } - Task { await self?.finish(id, items: items) } + // 先固定 weak 引用,避免发送给 Task 的闭包捕获可变的 self。 + let reader = self + Task { await reader?.finish(id, items: items) } } pending[id] = (token, continuation) } diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarMenuBarController.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarMenuBarController.swift index d7cc227..982f521 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarMenuBarController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarMenuBarController.swift @@ -11,6 +11,7 @@ final class CalendarMenuBarController: NSObject { private let model: AppModel private var item: NSStatusItem? private var panel: StatusPanel? + private var panelSizing: CalendarPopoverSizing? private var timer: Timer? private var hoverView: CalendarHoverView? private var hoverTask: Task? @@ -167,14 +168,17 @@ final class CalendarMenuBarController: NSObject { } func show() { - guard model.settings.calendarEnabled else { return } - if panel == nil { + // 菜单栏开关只控制常驻入口;全局快捷键仍可按需打开日历。 + let screen = item?.button?.window?.screen ?? NSApp.keyWindow?.screen ?? NSScreen.main + let sizing = CalendarPopoverSizing.fitting(visibleSize: screen?.visibleFrame.size ?? CGSize(width: 520, height: 900)) + if panel == nil || (panel?.isVisible == false && panelSizing != sizing) { let model = self.model - let panel = StatusPanel(width: CalendarPopover.width, minHeight: 380, - content: { CalendarPopover().environment(model) }, - measuring: { CalendarPopover().environment(model).environment(\.isSnapshot, true) }) + let panel = StatusPanel(width: sizing.width, minHeight: 380, maxHeight: sizing.maxHeight, + content: { CalendarPopover(sizing: sizing).environment(model) }, + measuring: { CalendarPopover(sizing: sizing).environment(model).environment(\.isSnapshot, true) }) panel.onVisibilityChange = { [weak self] visible in self?.item?.button?.highlight(visible) } self.panel = panel + panelSizing = sizing } refreshTitle() if let button = item?.button, let window = button.window, item?.isVisible == true { diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPopover.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPopover.swift index 160011c..14cc449 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPopover.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPopover.swift @@ -6,9 +6,32 @@ import EventKit import Localization import SwiftUI +/// 菜单栏月历随当前显示器的可用区域缩放;额外卡片超出高度时在面板内滚动。 +struct CalendarPopoverSizing: Equatable { + let width: CGFloat + let cellHeight: CGFloat + let dayFontSize: CGFloat + let subtitleFontSize: CGFloat + let headerPadding: CGFloat + let sectionSpacing: CGFloat + let maxHeight: CGFloat + + static let standard = fitting(visibleSize: CGSize(width: 1920, height: 1080)) + + static func fitting(visibleSize: CGSize) -> Self { + let compact = visibleSize.width < 1100 || visibleSize.height < 850 + let width = min(compact ? 460 : 520, max(1, visibleSize.width - 2 * DS.Space.s2)) + let maxHeight = min(640, max(360, visibleSize.height * 0.72), + max(1, visibleSize.height - DS.Space.s2)) + return .init(width: width, cellHeight: compact ? 48 : 56, + dayFontSize: compact ? 17 : 19, subtitleFontSize: compact ? 9 : 10, + headerPadding: compact ? 14 : 16, sectionSpacing: compact ? 12 : 14, + maxHeight: maxHeight) + } +} + /// 月历与黄历详情在同一个面板内切换;返回时保留浏览月份和选中的日期。 struct CalendarPopover: View { - static let width: CGFloat = 560 @Environment(AppModel.self) private var model @Environment(\.isSnapshot) private var isSnapshot @State private var month: CalendarMonth @@ -21,9 +44,12 @@ struct CalendarPopover: View { @State private var almanac: CalendarAlmanac? @State private var holidayPlan: CalendarHolidayPlan? private let referenceDate: Date? + private let sizing: CalendarPopoverSizing - init(referenceDate: Date? = nil, showsDayDetails: Bool = false) { + init(referenceDate: Date? = nil, showsDayDetails: Bool = false, + sizing: CalendarPopoverSizing = .standard) { self.referenceDate = referenceDate + self.sizing = sizing let today = CalendarEngine.today(at: referenceDate ?? Date()) let month = CalendarMonth(year: today?.year ?? 2026, month: today?.month ?? 1) _month = State(initialValue: month) @@ -44,9 +70,9 @@ struct CalendarPopover: View { if showsDayDetails { detailHeader(today: today) } else { header(today: today) } } - .padding(18) + .padding(sizing.headerPadding) PageScroll { - VStack(spacing: 16) { + VStack(spacing: sizing.sectionSpacing) { if showsDayDetails, let selected, let almanac { if model.settings.calendarPreferences.showEvents || model.settings.calendarPreferences.showReminders { CalendarAgendaView(day: selected) @@ -83,7 +109,7 @@ struct CalendarPopover: View { holidayPlan = CalendarEngine.holidayPlan(from: referenceDate ?? context.date) } } - .frame(width: isSnapshot ? Self.width : nil) + .frame(width: isSnapshot ? sizing.width : nil) .frame(maxWidth: isSnapshot ? nil : .infinity) .frame(maxHeight: isSnapshot ? nil : .infinity, alignment: .top) .fixedSize(horizontal: false, vertical: isSnapshot) @@ -167,24 +193,25 @@ struct CalendarPopover: View { private func calendarGrid(today: CalendarDay?) -> some View { let features = model.settings.calendarFeatures let firstWeekday = model.settings.calendarFirstWeekday - return VStack(spacing: 8) { + return VStack(spacing: sizing.cellHeight < 50 ? 6 : 7) { if features.contains(.weekdays) { HStack(spacing: 4) { ForEach(0..<7) { offset in let weekday = (firstWeekday - 1 + offset) % 7 + 1 Text(weekdayName(weekday)) - .font(.system(size: 12, weight: .medium)) + .font(.system(size: sizing.cellHeight < 50 ? 11 : 12, weight: .medium)) .foregroundStyle(weekday == 1 || weekday == 7 ? Color.red : .secondary) .frame(maxWidth: .infinity) } } - .padding(.bottom, 4) + .padding(.bottom, sizing.cellHeight < 50 ? 2 : 3) } - LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 4), count: 7), spacing: 5) { + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 4), count: 7), + spacing: sizing.cellHeight < 50 ? 3 : 4) { ForEach(days) { day in CalendarDayCell(day: day, features: features, isCurrentMonth: day.month == month.month, isToday: day.id == today?.id, isSelected: day.id == selected?.id, - preferences: model.settings.calendarPreferences, + preferences: model.settings.calendarPreferences, sizing: sizing, hasAgenda: model.calendarAgenda.items.contains { $0.occurs(on: day.date, calendar: CalendarEngine.gregorian()) }) { selected = day if day.month != month.month, CalendarMonth.years.contains(day.year) { @@ -265,6 +292,7 @@ private struct CalendarDayCell: View { let isToday: Bool let isSelected: Bool let preferences: CalendarPreferences + let sizing: CalendarPopoverSizing let hasAgenda: Bool let action: () -> Void @@ -274,19 +302,21 @@ private struct CalendarDayCell: View { var body: some View { Button(action: action) { - VStack(spacing: 3) { - Text(String(day.day)).font(.system(size: 21, weight: isToday ? .semibold : .regular, design: .rounded)) + VStack(spacing: sizing.cellHeight < 50 ? 2 : 3) { + Text(String(day.day)).font(.system(size: sizing.dayFontSize, + weight: isToday ? .semibold : .regular, design: .rounded)) Text(tr(day.subtitle(features: features))) - .font(.system(size: preferences.largeLunarText ? 13 : 10, weight: preferences.strongerLunarText ? .semibold : .regular)) + .font(.system(size: preferences.largeLunarText ? sizing.subtitleFontSize + 2 : sizing.subtitleFontSize, + weight: preferences.strongerLunarText ? .semibold : .regular)) .lineLimit(1) .minimumScaleFactor(0.75) if features.contains(.ganzhi) { - Text(day.ganzhiDay).font(.system(size: 9)).opacity(0.75) + Text(day.ganzhiDay).font(.system(size: sizing.cellHeight < 50 ? 8 : 9)).opacity(0.75) } } .foregroundStyle(foreground) .frame(maxWidth: .infinity) - .frame(height: preferences.largeLunarText ? 78 : 70) + .frame(height: sizing.cellHeight + (preferences.largeLunarText ? 8 : 0)) .background(isSelected ? Color.accentColor : holiday != nil ? foreground.opacity(0.05) : .clear, in: RoundedRectangle(cornerRadius: 10)) .overlay { diff --git a/Packages/XStatsKit/Tests/XStatsUITests/CalendarEnhancementTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/CalendarEnhancementTests.swift index ab752a6..de12d87 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/CalendarEnhancementTests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/CalendarEnhancementTests.swift @@ -167,4 +167,14 @@ struct CalendarEnhancementTests { #expect(frame.minY >= visible.minY) #expect(frame.maxY <= visible.maxY) } + + @Test func calendarPopoverShrinksWithAvailableScreenSpace() { + let desktop = CalendarPopoverSizing.fitting(visibleSize: CGSize(width: 1920, height: 1080)) + let laptop = CalendarPopoverSizing.fitting(visibleSize: CGSize(width: 1024, height: 700)) + #expect(desktop.width < 560) + #expect(desktop.maxHeight <= 640) + #expect(laptop.width < desktop.width) + #expect(laptop.cellHeight < desktop.cellHeight) + #expect(laptop.maxHeight <= 700 * 0.75) + } } diff --git a/Packages/XStatsKit/Tests/XStatsUITests/XStatsUITests.swift b/Packages/XStatsKit/Tests/XStatsUITests/XStatsUITests.swift index d20f4f4..10d5c8a 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/XStatsUITests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/XStatsUITests.swift @@ -763,6 +763,16 @@ private func isolatedDefaults() -> UserDefaults { } @Suite struct HotKeyTests { + @Test func calendarActionKeepsExistingCarbonIDs() throws { + let calendar = try #require(HotKeyAction(rawValue: "openCalendar")) + #expect(HotKeyAction.allCases.last == calendar) + #expect(calendar.carbonID == 5) + #expect(HotKeyAction.toggleMainWindow.carbonID == 1) + #expect(HotKeyAction.showProcesses.carbonID == 2) + #expect(HotKeyAction.toggleKeepAwake.carbonID == 3) + #expect(HotKeyAction.purgeMemory.carbonID == 4) + } + @Test func displaysAndValidates() { let hotKey = HotKey(keyCode: 1, modifiers: [.command, .option, .capsLock], key: "s") #expect(hotKey.display == "⌥⌘S") @@ -776,8 +786,10 @@ private func isolatedDefaults() -> UserDefaults { let defaults = isolatedDefaults() let settings = AppSettings(defaults: defaults) settings.hotKeys[.toggleKeepAwake] = HotKey(keyCode: 40, modifiers: [.command, .shift], key: "k") + settings.hotKeys[.openCalendar] = HotKey(keyCode: 37, modifiers: [.command, .option], key: "l") let reloaded = AppSettings(defaults: defaults) #expect(reloaded.hotKeys[.toggleKeepAwake]?.display == "⇧⌘K") + #expect(reloaded.hotKeys[.openCalendar]?.display == "⌥⌘L") } }