diff --git a/MeetingBar/Extensions/DefaultsKeys.swift b/MeetingBar/Extensions/DefaultsKeys.swift index ed04ed56..5ea19627 100644 --- a/MeetingBar/Extensions/DefaultsKeys.swift +++ b/MeetingBar/Extensions/DefaultsKeys.swift @@ -39,6 +39,7 @@ extension Defaults.Keys { static let eventTimeFormat = Key("eventTimeFormat", default: .show) static let eventTitleIconFormat = Key("eventTitleIconFormat", default: .none) + static let showDateOnIcon = Key("showDateOnIcon", default: false) static let statusbarEventTitleLength = Key("statusbarEventTitleLength", default: statusbarEventTitleLengthLimits.max) static let hideMeetingTitle = Key("hideMeetingTitle", default: false) diff --git a/MeetingBar/Resources /Localization /en.lproj/Localizable.strings b/MeetingBar/Resources /Localization /en.lproj/Localizable.strings index 5c95734f..b72b113a 100644 --- a/MeetingBar/Resources /Localization /en.lproj/Localizable.strings +++ b/MeetingBar/Resources /Localization /en.lproj/Localizable.strings @@ -88,6 +88,7 @@ "preferences_appearance_status_bar_icon_calendar_icon_value" = "\U00A0Calendar"; "preferences_appearance_status_bar_icon_specific_icon_value" = "\U00A0Event-specific icon (e.g. MS Teams)"; "preferences_appearance_status_bar_icon_no_icon_value" = "\U00A0No icon"; +"preferences_appearance_status_bar_show_date_on_icon" = "Show today's date on icon"; "preferences_appearance_status_bar_title_title" = "Title"; "preferences_appearance_status_bar_title_event_title_value" = "event title"; "preferences_appearance_status_bar_title_dot_value" = "dot (•)"; diff --git a/MeetingBar/UI/StatusBar/StatusBarItemController.swift b/MeetingBar/UI/StatusBar/StatusBarItemController.swift index f663446a..fe9e1665 100644 --- a/MeetingBar/UI/StatusBar/StatusBarItemController.swift +++ b/MeetingBar/UI/StatusBar/StatusBarItemController.swift @@ -64,7 +64,7 @@ final class StatusBarItemController { // For all these keys, just redraw: Defaults.publisher( keys: .statusbarEventTitleLength, .eventTimeFormat, - .eventTitleIconFormat, .showEventMaxTimeUntilEventThreshold, + .eventTitleIconFormat, .showDateOnIcon, .showEventMaxTimeUntilEventThreshold, .showEventMaxTimeUntilEventEnabled, .showEventDetails, .shortenEventTitle, .menuEventTitleLength, .showEventEndTime, .showMeetingServiceIcon, @@ -220,7 +220,7 @@ final class StatusBarItemController { case .appicon: button.image = NSImage(named: Defaults[.eventTitleIconFormat].rawValue)! default: - button.image = NSImage(named: MenuStyleConstants.calendarCheckmarkIconName) + button.image = calendarIconWithOptionalDate(named: MenuStyleConstants.calendarCheckmarkIconName) } button.image?.size = MenuStyleConstants.iconSize } else if title == "MeetingBar" { @@ -231,7 +231,7 @@ final class StatusBarItemController { case .appicon: button.image = NSImage(named: Defaults[.eventTitleIconFormat].rawValue)! default: - button.image = NSImage(named: MenuStyleConstants.calendarIconName) + button.image = calendarIconWithOptionalDate(named: MenuStyleConstants.calendarIconName) } } @@ -240,6 +240,8 @@ final class StatusBarItemController { let image: NSImage if Defaults[.eventTitleIconFormat] == .eventtype { image = getIconForMeetingService(nextEvent.meetingLink?.service) + } else if Defaults[.eventTitleIconFormat] == .calendar { + image = calendarIconWithOptionalDate(named: Defaults[.eventTitleIconFormat].rawValue) } else { image = NSImage(named: Defaults[.eventTitleIconFormat].rawValue)! } @@ -523,6 +525,62 @@ final class StatusBarItemController { openInFantastical(startDate: event.startDate, title: event.title) } } + + /// Returns the named calendar icon, optionally with the current day-of-month overlaid. + private func calendarIconWithOptionalDate(named name: String) -> NSImage { + let baseImage = NSImage(named: name)! + return makeCalendarIcon( + baseImage: baseImage, + iconFormat: Defaults[.eventTitleIconFormat], + showDate: Defaults[.showDateOnIcon], + date: Date(), + size: MenuStyleConstants.iconSize + ) + } +} + +/// Composites the day-of-month onto a calendar icon when the user opted in. +/// Extracted as a free function so it can be unit-tested without spinning up +/// `StatusBarItemController` (which requires `NSStatusBar` and is `@MainActor`). +@MainActor +func makeCalendarIcon( + baseImage: NSImage, + iconFormat: EventTitleIconFormat, + showDate: Bool, + date: Date, + size: NSSize +) -> NSImage { + let canShowDate = iconFormat == .calendar || iconFormat == .eventtype + guard showDate, canShowDate else { return baseImage } + + let day = Calendar.current.component(.day, from: date) + let dayString = String(day) + + let composed = NSImage(size: size) + composed.lockFocus() + + baseImage.draw(in: NSRect(origin: .zero, size: size), + from: .zero, + operation: .sourceOver, + fraction: 1.0) + + // Pick a font size that fits one or two digits inside the calendar body. + let fontSize: CGFloat = dayString.count > 1 ? 8 : 9 + let attributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: fontSize, weight: .bold), + .foregroundColor: NSColor.labelColor + ] + let textSize = (dayString as NSString).size(withAttributes: attributes) + // Calendar icon has a header strip at the top; nudge text down into the body. + let textOrigin = NSPoint( + x: (size.width - textSize.width) / 2, + y: (size.height - textSize.height) / 2 - 1.5 + ) + (dayString as NSString).draw(at: textOrigin, withAttributes: attributes) + + composed.unlockFocus() + composed.isTemplate = baseImage.isTemplate + return composed } func shortenTitle(title: String?, offset: Int) -> String { diff --git a/MeetingBar/UI/Views/Preferences/AppearanceTab.swift b/MeetingBar/UI/Views/Preferences/AppearanceTab.swift index 38c25073..e9579bf8 100644 --- a/MeetingBar/UI/Views/Preferences/AppearanceTab.swift +++ b/MeetingBar/UI/Views/Preferences/AppearanceTab.swift @@ -143,6 +143,7 @@ struct EventsSection: View { struct StatusBarSection: View { @Default(.eventTitleIconFormat) var eventTitleIconFormat + @Default(.showDateOnIcon) var showDateOnIcon @Default(.eventTitleFormat) var eventTitleFormat @Default(.eventTimeFormat) var eventTimeFormat @Default(.statusbarEventTitleLength) var statusbarEventTitleLength @@ -188,6 +189,14 @@ struct StatusBarSection: View { } }.frame(width: 325) + HStack { + Toggle( + "preferences_appearance_status_bar_show_date_on_icon".loco(), + isOn: $showDateOnIcon + ) + .disabled(eventTitleIconFormat == .appicon || eventTitleIconFormat == .none) + }.frame(width: 325, alignment: .leading) + HStack { Picker( "preferences_appearance_status_bar_title_title".loco(), diff --git a/MeetingBarTests/StatusBarItem/CalendarIconTests.swift b/MeetingBarTests/StatusBarItem/CalendarIconTests.swift new file mode 100644 index 00000000..e5cbc4a8 --- /dev/null +++ b/MeetingBarTests/StatusBarItem/CalendarIconTests.swift @@ -0,0 +1,131 @@ +// +// CalendarIconTests.swift +// MeetingBar +// +// Copyright © 2026 Andrii Leitsius. All rights reserved. +// + +import XCTest +import AppKit +@testable import MeetingBar + +@MainActor +final class CalendarIconTests: BaseTestCase { + + private let iconSize = NSSize(width: 16, height: 16) + + private func makeBase(template: Bool = true) -> NSImage { + let image = NSImage(size: iconSize) + image.lockFocus() + NSColor.black.setFill() + NSRect(origin: .zero, size: iconSize).fill() + image.unlockFocus() + image.isTemplate = template + return image + } + + private func date(day: Int) -> Date { + var components = DateComponents() + components.year = 2026 + components.month = 1 + components.day = day + return Calendar.current.date(from: components)! + } + + func testReturnsBaseImageWhenShowDateDisabled() { + let base = makeBase() + let result = makeCalendarIcon( + baseImage: base, + iconFormat: .calendar, + showDate: false, + date: date(day: 15), + size: iconSize + ) + XCTAssertTrue(result === base, "Should return the base image unchanged when showDate is false") + } + + func testReturnsBaseImageWhenIconFormatIsAppIcon() { + let base = makeBase() + let result = makeCalendarIcon( + baseImage: base, + iconFormat: .appicon, + showDate: true, + date: date(day: 15), + size: iconSize + ) + XCTAssertTrue(result === base, "Should not overlay date when format is .appicon") + } + + func testReturnsBaseImageWhenIconFormatIsNone() { + let base = makeBase() + let result = makeCalendarIcon( + baseImage: base, + iconFormat: .none, + showDate: true, + date: date(day: 15), + size: iconSize + ) + XCTAssertTrue(result === base, "Should not overlay date when format is .none") + } + + func testReturnsComposedImageWhenEnabledForCalendar() { + let base = makeBase() + let result = makeCalendarIcon( + baseImage: base, + iconFormat: .calendar, + showDate: true, + date: date(day: 15), + size: iconSize + ) + XCTAssertFalse(result === base, "Should return a new composed image when overlay is active") + XCTAssertEqual(result.size, iconSize) + } + + func testReturnsComposedImageWhenEnabledForEventType() { + let base = makeBase() + let result = makeCalendarIcon( + baseImage: base, + iconFormat: .eventtype, + showDate: true, + date: date(day: 7), + size: iconSize + ) + XCTAssertFalse(result === base) + XCTAssertEqual(result.size, iconSize) + } + + func testComposedImagePreservesTemplateFlag() { + let templateBase = makeBase(template: true) + let templateResult = makeCalendarIcon( + baseImage: templateBase, + iconFormat: .calendar, + showDate: true, + date: date(day: 1), + size: iconSize + ) + XCTAssertTrue(templateResult.isTemplate, "Template flag should propagate so the menu bar tints correctly") + + let nonTemplateBase = makeBase(template: false) + let nonTemplateResult = makeCalendarIcon( + baseImage: nonTemplateBase, + iconFormat: .calendar, + showDate: true, + date: date(day: 1), + size: iconSize + ) + XCTAssertFalse(nonTemplateResult.isTemplate) + } + + func testHandlesTwoDigitDay() { + // Just verifies the two-digit branch doesn't crash and still returns a usable image. + let base = makeBase() + let result = makeCalendarIcon( + baseImage: base, + iconFormat: .calendar, + showDate: true, + date: date(day: 28), + size: iconSize + ) + XCTAssertEqual(result.size, iconSize) + } +}