feat(calendar): 新增全局快捷键并适配弹窗尺寸

This commit is contained in:
ysicing committed 2026-09-28 22:16:38 +08:00
1 parent 0a26452d7e
commit 8ee7061c06
8 files changed
+90 -24

No files matched your search

@@ -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()
}
}
@@ -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("打开日历")
}
}
@@ -22,11 +22,14 @@ final class StatusPanel: NSPanel {
private let width: CGFloat
/// 内容再少也不低于这个高度;合并模式的状态总览项目少时很矮,用更小的下限
private let minHeight: CGFloat
private let maxHeight: CGFloat?
init<Content: View, Measuring: View>(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))
}
/// 显示器可用区始终优先于首选尺寸,窄屏和低分辨率下不能让最小高度把面板挤出屏幕。
@@ -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)
}
@@ -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<Void, Never>?
@@ -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 {
@@ -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 {
@@ -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)
}
}
@@ -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")
}
}