Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions MeetingBar/Extensions/DefaultsKeys.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ extension Defaults.Keys {
static let eventTimeFormat = Key<EventTimeFormat>("eventTimeFormat", default: .show)

static let eventTitleIconFormat = Key<EventTitleIconFormat>("eventTitleIconFormat", default: .none)
static let showDateOnIcon = Key<Bool>("showDateOnIcon", default: false)
static let statusbarEventTitleLength = Key<Int>("statusbarEventTitleLength", default: statusbarEventTitleLengthLimits.max)

static let hideMeetingTitle = Key<Bool>("hideMeetingTitle", default: false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (•)";
Expand Down
64 changes: 61 additions & 3 deletions MeetingBar/UI/StatusBar/StatusBarItemController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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" {
Expand All @@ -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)
}
}

Expand All @@ -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)!
}
Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions MeetingBar/UI/Views/Preferences/AppearanceTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down
131 changes: 131 additions & 0 deletions MeetingBarTests/StatusBarItem/CalendarIconTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}

Check warning

Code scanning / Tailor (reported by Codacy)

Function should have at least one blank line after it Warning

Function should have at least one blank line after it
}
Loading