feat(calendar): 新增全局快捷键并适配弹窗尺寸
This commit is contained in:
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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user