diff --git a/LunarBar.xcodeproj/project.pbxproj b/LunarBar.xcodeproj/project.pbxproj index c792fd9..fda1300 100644 --- a/LunarBar.xcodeproj/project.pbxproj +++ b/LunarBar.xcodeproj/project.pbxproj @@ -37,6 +37,7 @@ 87D65EB62B412A4700E41049 /* AppDefinitionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87D65EB52B412A4700E41049 /* AppDefinitionsTests.swift */; }; 87DA5AFF2B3433D400CE2C1A /* WeekdayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87DA5AFE2B3433D400CE2C1A /* WeekdayView.swift */; }; 87F81C542B43EBDE0071CA30 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87F81C532B43EBDE0071CA30 /* main.swift */; }; + 87FB0ECB2CEDBBE700667C4C /* ScalableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87FB0ECA2CEDBBE300667C4C /* ScalableView.swift */; }; 87FF2E822CE89F6700CBC2E0 /* DateDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87FF2E812CE89F6000CBC2E0 /* DateDetailsView.swift */; }; /* End PBXBuildFile section */ @@ -86,6 +87,7 @@ 87DA5AFE2B3433D400CE2C1A /* WeekdayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekdayView.swift; sourceTree = ""; }; 87F81C532B43EBDE0071CA30 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; 87F862152B33DE1E00857541 /* Build.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Build.xcconfig; sourceTree = ""; }; + 87FB0ECA2CEDBBE300667C4C /* ScalableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScalableView.swift; sourceTree = ""; }; 87FF2E812CE89F6000CBC2E0 /* DateDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateDetailsView.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -243,6 +245,7 @@ 8782F4822B342413008B1912 /* HeaderView.swift */, 87DA5AFE2B3433D400CE2C1A /* WeekdayView.swift */, 8740E30A2B37F969004A06C2 /* EventView.swift */, + 87FB0ECA2CEDBBE300667C4C /* ScalableView.swift */, 87A28A5C2B344D50006655F2 /* DateGridView.swift */, 873424CF2B35357B00C364BF /* DateGridCell.swift */, 87FF2E812CE89F6000CBC2E0 /* DateDetailsView.swift */, @@ -391,6 +394,7 @@ 8740E30F2B37FB36004A06C2 /* CalendarManager.swift in Sources */, 8714291C2B3DBFDF003FA2CB /* AppMainVC+Menu.swift in Sources */, 8740E30B2B37F969004A06C2 /* EventView.swift in Sources */, + 87FB0ECB2CEDBBE700667C4C /* ScalableView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/LunarBarMac/Modules/Sources/AppKitControls/ImageButton.swift b/LunarBarMac/Modules/Sources/AppKitControls/ImageButton.swift index 744ee2e..f972bdc 100644 --- a/LunarBarMac/Modules/Sources/AppKitControls/ImageButton.swift +++ b/LunarBarMac/Modules/Sources/AppKitControls/ImageButton.swift @@ -41,8 +41,10 @@ public final class ImageButton: CustomButton { addSubview(iconView) NSLayoutConstraint.activate([ - iconView.centerXAnchor.constraint(equalTo: centerXAnchor), - iconView.centerYAnchor.constraint(equalTo: centerYAnchor), + iconView.leadingAnchor.constraint(equalTo: leadingAnchor), + iconView.trailingAnchor.constraint(equalTo: trailingAnchor), + iconView.topAnchor.constraint(equalTo: topAnchor), + iconView.bottomAnchor.constraint(equalTo: bottomAnchor), ]) setFrameSize(CGSize( diff --git a/LunarBarMac/Resources/Localizable.xcstrings b/LunarBarMac/Resources/Localizable.xcstrings index eeb5763..9d6c2a3 100644 --- a/LunarBarMac/Resources/Localizable.xcstrings +++ b/LunarBarMac/Resources/Localizable.xcstrings @@ -351,6 +351,57 @@ } } }, + "Color Scheme" : { + "comment" : "[Menu] Section title for color schemes", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "颜色偏好" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "顏色偏好" + } + } + } + }, + "Compact" : { + "comment" : "[Menu] Content scale: compact", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "紧凑" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "緊湊" + } + } + } + }, + "Content Scale" : { + "comment" : "[Menu] Section title for content scales", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "缩放显示" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "縮放顯示" + } + } + } + }, "Current Date" : { "comment" : "[Menu] Use the current date as the menu bar icon", "localizations" : { @@ -1178,6 +1229,23 @@ } } }, + "Default" : { + "comment" : "[Menu] Content scale: default", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "默认" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "預設" + } + } + } + }, "Default (Mainland China)" : { "comment" : "[Menu] Default public holidays (Mainland China)", "localizations" : { @@ -1469,6 +1537,23 @@ } } }, + "Icon" : { + "comment" : "[Menu] Section title for icons", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "图标" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "圖像" + } + } + } + }, "Jingzhe" : { "comment" : "The 3rd solar term, always in Chinese", "localizations" : { @@ -2379,6 +2464,23 @@ } } }, + "Roomy" : { + "comment" : "[Menu] Content scale: roomy", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "宽松" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "寬鬆" + } + } + } + }, "Select All" : { "comment" : "[Menu] Select all calendars", "localizations" : { diff --git a/LunarBarMac/Sources/Main/AppDelegate.swift b/LunarBarMac/Sources/Main/AppDelegate.swift index 0adfe9c..d8d5d4b 100644 --- a/LunarBarMac/Sources/Main/AppDelegate.swift +++ b/LunarBarMac/Sources/Main/AppDelegate.swift @@ -156,6 +156,28 @@ class AppDelegate: NSObject, NSApplicationDelegate { DateFormatter.lunarDate.string(from: currentDate).removingLeadingDigits, ].joined(separator: "\n\n") } + + @MainActor + func openPanel() { + guard let sender = statusItem.button else { + return Logger.assertFail("Missing source view to proceed") + } + + let popover = AppMainVC.createPopover() + popover.delegate = self + popover.show(relativeTo: sender.bounds, of: sender, preferredEdge: .maxY) + presentedPopover = popover + + // Ensure the app is activated and the window is key and ordered front + NSApp.activate(ignoringOtherApps: true) + popover.contentViewController?.view.window?.makeKeyAndOrderFront(nil) + + // Keep the button highlighted to mimic the system behavior + sender.highlight(true) + + // Clear the tooltip to prevent overlap + sender.toolTip = nil + } } // MARK: - NSPopoverDelegate @@ -227,25 +249,4 @@ private extension AppDelegate { return true } - - func openPanel() { - guard let sender = statusItem.button else { - return Logger.assertFail("Missing source view to proceed") - } - - let popover = AppMainVC.createPopover() - popover.delegate = self - popover.show(relativeTo: sender.bounds, of: sender, preferredEdge: .maxY) - presentedPopover = popover - - // Ensure the app is activated and the window is key and ordered front - NSApp.activate(ignoringOtherApps: true) - popover.contentViewController?.view.window?.makeKeyAndOrderFront(nil) - - // Keep the button highlighted to mimic the system behavior - sender.highlight(true) - - // Clear the tooltip to prevent overlap - sender.toolTip = nil - } } diff --git a/LunarBarMac/Sources/Main/AppMainVC+Menu.swift b/LunarBarMac/Sources/Main/AppMainVC+Menu.swift index dfa93c4..dd46684 100644 --- a/LunarBarMac/Sources/Main/AppMainVC+Menu.swift +++ b/LunarBarMac/Sources/Main/AppMainVC+Menu.swift @@ -129,18 +129,7 @@ private extension AppMainVC { let menu = NSMenu() // Icon styles - menu.addItem({ - let item = NSMenuItem(title: Localized.UI.menuTitleCalendarIcon) - item.image = AppIconFactory.createCalendarIcon(pointSize: 14) - item.setOn(AppPreferences.General.menuBarIcon == .calendar) - - item.addAction { - AppPreferences.General.menuBarIcon = .calendar - } - - return item - }()) - + menu.addItem(withTitle: Localized.UI.menuTitleMenuBarIcon).isEnabled = false menu.addItem({ let item = NSMenuItem(title: Localized.UI.menuTitleCurrentDate) item.setOn(AppPreferences.General.menuBarIcon == .date) @@ -158,13 +147,26 @@ private extension AppMainVC { return item }()) + menu.addItem({ + let item = NSMenuItem(title: Localized.UI.menuTitleCalendarIcon) + item.image = AppIconFactory.createCalendarIcon(pointSize: 14) + item.setOn(AppPreferences.General.menuBarIcon == .calendar) + + item.addAction { + AppPreferences.General.menuBarIcon = .calendar + } + + return item + }()) + menu.addSeparator() // Dark mode preferences + menu.addItem(withTitle: Localized.UI.menuTitleColorScheme).isEnabled = false [ + (Localized.UI.menuTitleSystem, Appearance.system), (Localized.UI.menuTitleLight, Appearance.light), (Localized.UI.menuTitleDark, Appearance.dark), - (Localized.UI.menuTitleSystem, Appearance.system), ].forEach { (title: String, appearance: Appearance) in menu.addItem(withTitle: title) { [weak self] in self?.updateAppearance(appearance) @@ -174,6 +176,28 @@ private extension AppMainVC { menu.addSeparator() + // Content scale preferences + menu.addItem(withTitle: Localized.UI.menuTitleContentScale).isEnabled = false + [ + (Localized.UI.menuTitleScaleDefault, ContentScale.default), + (Localized.UI.menuTitleScaleCompact, ContentScale.compact), + (Localized.UI.menuTitleScaleRoomy, ContentScale.roomy), + ].forEach { (title: String, scale: ContentScale) in + menu.addItem(withTitle: title) { [weak self] in + AppPreferences.General.contentScale = scale + self?.popover?.close() + + if let delegate = NSApp.delegate as? AppDelegate { + delegate.openPanel() + } else { + Logger.assertFail("Unexpected app delegate: \(String(describing: NSApp.delegate))") + } + } + .setOn(AppPreferences.General.contentScale == scale) + } + + menu.addSeparator() + // Accessibility options menu.addItem(withTitle: Localized.UI.menuTitleReduceMotion) { [weak self] in AppPreferences.Accessibility.reduceMotion.toggle() diff --git a/LunarBarMac/Sources/Main/AppMainVC.swift b/LunarBarMac/Sources/Main/AppMainVC.swift index c0eaead..c5dbe0d 100644 --- a/LunarBarMac/Sources/Main/AppMainVC.swift +++ b/LunarBarMac/Sources/Main/AppMainVC.swift @@ -18,6 +18,7 @@ final class AppMainVC: NSViewController { weak var popover: NSPopover? // Views + private let scalableView = ScalableView() private let headerView = HeaderView() private let weekdayView = WeekdayView() private let dateGridView = DateGridView() @@ -26,7 +27,7 @@ final class AppMainVC: NSViewController { static func createPopover() -> NSPopover { let popover = NSPopover() popover.behavior = .transient - popover.contentSize = Constants.contentSize + popover.contentSize = desiredContentSize popover.animates = !AppPreferences.Accessibility.reduceMotion let contentVC = Self() @@ -42,7 +43,8 @@ final class AppMainVC: NSViewController { extension AppMainVC { override func loadView() { // Required prior to macOS Sonoma - view = NSView(frame: CGRect(origin: .zero, size: Constants.contentSize)) + view = NSView(frame: CGRect(origin: .zero, size: Self.desiredContentSize)) + view.addScalableView(scalableView, scale: AppPreferences.General.contentScale.rawValue) } override func viewDidLoad() { @@ -113,13 +115,20 @@ extension AppMainVC: HeaderViewDelegate { private extension AppMainVC { enum Constants { - static let contentSize = CGSize(width: 240, height: 320) static let headerViewHeight: Double = 40 static let weekdayViewHeight: Double = 17 static let dateGridViewMarginTop: Double = 10 } + @MainActor static var desiredContentSize: CGSize { + CGSize( + width: 240 * AppPreferences.General.contentScale.rawValue, + height: 320 * AppPreferences.General.contentScale.rawValue + ) + } + func setUp() { + let view = scalableView.container headerView.delegate = self headerView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(headerView) diff --git a/LunarBarMac/Sources/Shared/AppDefinitions.swift b/LunarBarMac/Sources/Shared/AppDefinitions.swift index 1dc2630..175f54e 100644 --- a/LunarBarMac/Sources/Shared/AppDefinitions.swift +++ b/LunarBarMac/Sources/Shared/AppDefinitions.swift @@ -27,11 +27,17 @@ enum Localized { static let menuTitleGotoMonth = String(localized: "Go to Month", comment: "[Menu] Select year and month") static let menuTitleEnterMonth = String(localized: "Enter Month", comment: "[Menu] Enter a month using date picker") static let menuTitleAppearance = String(localized: "Appearance", comment: "[Menu] Change dark mode preference") - static let menuTitleCalendarIcon = String(localized: "Calendar Icon", comment: "[Menu] Use a calendar icon as the menu bar icon") + static let menuTitleMenuBarIcon = String(localized: "Icon", comment: "[Menu] Section title for icons") static let menuTitleCurrentDate = String(localized: "Current Date", comment: "[Menu] Use the current date as the menu bar icon") + static let menuTitleCalendarIcon = String(localized: "Calendar Icon", comment: "[Menu] Use a calendar icon as the menu bar icon") + static let menuTitleColorScheme = String(localized: "Color Scheme", comment: "[Menu] Section title for color schemes") + static let menuTitleSystem = String(localized: "System", comment: "[Menu] Follow the system appearance") static let menuTitleLight = String(localized: "Light", comment: "[Menu] Use the light appearance") static let menuTitleDark = String(localized: "Dark", comment: "[Menu] Use the dark appearance") - static let menuTitleSystem = String(localized: "System", comment: "[Menu] Follow the system appearance") + static let menuTitleContentScale = String(localized: "Content Scale", comment: "[Menu] Section title for content scales") + static let menuTitleScaleDefault = String(localized: "Default", comment: "[Menu] Content scale: default") + static let menuTitleScaleCompact = String(localized: "Compact", comment: "[Menu] Content scale: compact") + static let menuTitleScaleRoomy = String(localized: "Roomy", comment: "[Menu] Content scale: roomy") static let menuTitleReduceMotion = String(localized: "Reduce Motion", comment: "[Menu] Disable animations when presenting the calendar popover") static let menuTitleReduceTransparency = String(localized: "Reduce Transparency", comment: "[Menu] Reduce transparency of the calendar panel") static let menuTitleFloatOnTop = String(localized: "Float on Top", comment: "[Menu] Float the popover on top") diff --git a/LunarBarMac/Sources/Shared/AppPreferences.swift b/LunarBarMac/Sources/Shared/AppPreferences.swift index 96cf756..4b325c5 100644 --- a/LunarBarMac/Sources/Shared/AppPreferences.swift +++ b/LunarBarMac/Sources/Shared/AppPreferences.swift @@ -29,6 +29,9 @@ enum AppPreferences { @Storage(key: "general.appearance", defaultValue: .system) static var appearance: Appearance + + @Storage(key: "general.content-scale", defaultValue: .default) + static var contentScale: ContentScale } enum Calendar { @@ -59,28 +62,34 @@ enum AppPreferences { // MARK: - Types enum MenuBarIcon: Codable { - case calendar case date + case calendar } enum Appearance: Codable { + case system case light case dark - case system @MainActor func resolved(with appearance: NSAppearance = NSApp.effectiveAppearance) -> NSAppearance? { switch self { + case .system: + return nil case .light: return NSAppearance(named: appearance.resolvedName(isDarkMode: false)) case .dark: return NSAppearance(named: appearance.resolvedName(isDarkMode: true)) - case .system: - return nil } } } +enum ContentScale: Double, Codable { + case `default` = 1.0 + case compact = 0.9 + case roomy = 1.1 +} + @MainActor @propertyWrapper struct Storage { diff --git a/LunarBarMac/Sources/Views/DateDetailsView.swift b/LunarBarMac/Sources/Views/DateDetailsView.swift index 38fa428..986dfaa 100644 --- a/LunarBarMac/Sources/Views/DateDetailsView.swift +++ b/LunarBarMac/Sources/Views/DateDetailsView.swift @@ -18,11 +18,12 @@ struct DateDetailsView: View { private let events: [EKCalendarItem] var body: some View { + let scale = AppPreferences.General.contentScale.rawValue VStack(spacing: 0) { Text(title) - .font(.system(size: Constants.fontSize, weight: .medium)) - .frame(height: Constants.rowHeight) - .padding(.horizontal, Constants.smallPadding) + .font(font(weight: .medium, scale: scale)) + .frame(height: Constants.rowHeight * scale) + .padding(.horizontal, Constants.smallPadding * scale) if !events.isEmpty { Divider() @@ -33,34 +34,39 @@ struct DateDetailsView: View { HStack { Circle() .fill(Color(event.calendar.color)) - .frame(width: Constants.dotSize, height: Constants.dotSize) + .frame(width: Constants.dotSize * scale, height: Constants.dotSize * scale) Text(event.title) - .font(.system(size: Constants.fontSize)) + .font(font(weight: .regular, scale: scale)) .frame(maxWidth: .infinity, alignment: .leading) .strikethrough(event.isCompletedItem) - Spacer(minLength: Constants.largePadding) + Spacer(minLength: Constants.largePadding * scale) Text(event.labelOfDates) - .font(.system(size: Constants.fontSize)) + .font(font(weight: .regular, scale: scale)) .frame(alignment: .trailing) .fixedSize() } - .frame(height: Constants.rowHeight) + .frame(height: Constants.rowHeight * scale) if index < events.count - 1 { Divider() } } - .padding(.horizontal, Constants.smallPadding) + .padding(.horizontal, Constants.smallPadding * scale) // Indicator for more events if events.count > Constants.maximumRows { Image(systemName: "ellipsis") .foregroundStyle(.secondary) - .padding(.vertical, 2) + .padding(.vertical, 2) // Tiny element, no need to scale } } } + func font(weight: Font.Weight, scale: Double) -> Font { + // The minimum acceptable font size for readability is 11 point + .system(size: max(Constants.fontSize * scale, 11.0), weight: weight) + } + static func createPopover(title: String, events: [EKCalendarItem]) -> NSPopover { let popover = NSPopover() popover.behavior = .applicationDefined @@ -111,7 +117,11 @@ private final class DateDetailsHostVC: NSViewController { super.viewDidLayout() var contentSize = contentView.fittingSize - contentSize.width = min(Constants.maximumWidth, contentSize.width) + contentSize.width = min( + Constants.maximumWidth * AppPreferences.General.contentScale.rawValue, + contentSize.width + ) + preferredContentSize = contentSize } } diff --git a/LunarBarMac/Sources/Views/ScalableView.swift b/LunarBarMac/Sources/Views/ScalableView.swift new file mode 100644 index 0000000..6eb95d1 --- /dev/null +++ b/LunarBarMac/Sources/Views/ScalableView.swift @@ -0,0 +1,64 @@ +// +// ScalableView.swift +// LunarBarMac +// +// Created by cyan on 11/20/24. +// + +import AppKit + +/** + Scalable wrapper to easily scale subviews. + + Views and constraints must be added to the `container` to be scalable. + */ +final class ScalableView: NSScrollView { + let container = NSView() + + init() { + super.init(frame: .zero) + drawsBackground = false + backgroundColor = .clear + + documentView = container + documentView?.translatesAutoresizingMaskIntoConstraints = false + + hasVerticalScroller = false + hasHorizontalScroller = false + verticalScrollElasticity = .none + horizontalScrollElasticity = .none + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func scrollWheel(with event: NSEvent) { + // no-op + } +} + +extension NSView { + func addScalableView(_ scalableView: ScalableView, scale: Double) { + let wrapper = scalableView + let container = scalableView.container + + wrapper.magnification = scale + wrapper.translatesAutoresizingMaskIntoConstraints = false + addSubview(wrapper) + + NSLayoutConstraint.activate([ + // The wrapper is always full size + wrapper.leadingAnchor.constraint(equalTo: leadingAnchor), + wrapper.trailingAnchor.constraint(equalTo: trailingAnchor), + wrapper.topAnchor.constraint(equalTo: topAnchor), + wrapper.bottomAnchor.constraint(equalTo: bottomAnchor), + // The container is scaled and possibly clipped + container.leadingAnchor.constraint(equalTo: leadingAnchor), + container.bottomAnchor.constraint(equalTo: bottomAnchor), + container.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 1.0 / scale), + container.heightAnchor.constraint(equalTo: heightAnchor, multiplier: 1.0 / scale), + ]) + } +}