|
|
|
// Copyright © 2015 Abhishek Banthia
|
|
|
|
|
|
|
|
import Cocoa
|
|
|
|
import CoreLoggerKit
|
|
|
|
import CoreModelKit
|
|
|
|
|
|
|
|
func compactWidth(for timezone: TimezoneData, with store: DataStore) -> Int {
|
|
|
|
var totalWidth = 55
|
|
|
|
let timeFormat = timezone.timezoneFormat(store.timezoneFormat())
|
|
|
|
|
|
|
|
if store.shouldShowDayInMenubar() {
|
|
|
|
totalWidth += 12
|
|
|
|
}
|
|
|
|
|
|
|
|
if timeFormat == DateFormat.twelveHour
|
|
|
|
|| timeFormat == DateFormat.twelveHourWithSeconds
|
|
|
|
|| timeFormat == DateFormat.twelveHourWithZero
|
|
|
|
|| timeFormat == DateFormat.twelveHourWithSeconds
|
|
|
|
{
|
|
|
|
totalWidth += 20
|
|
|
|
} else if timeFormat == DateFormat.twentyFourHour
|
|
|
|
|| timeFormat == DateFormat.twentyFourHourWithSeconds
|
|
|
|
{
|
|
|
|
totalWidth += 0
|
|
|
|
}
|
|
|
|
|
|
|
|
if timezone.shouldShowSeconds(store.timezoneFormat()) {
|
|
|
|
// Slight buffer needed when the Menubar supplementary text was Mon 9:27:58 AM
|
|
|
|
totalWidth += 15
|
|
|
|
}
|
|
|
|
|
|
|
|
if store.shouldShowDateInMenubar() {
|
|
|
|
totalWidth += 20
|
|
|
|
}
|
|
|
|
|
|
|
|
return totalWidth
|
|
|
|
}
|
|
|
|
|
|
|
|
// Test with Sat 12:46 AM
|
|
|
|
let bufferWidth: CGFloat = 9.5
|
|
|
|
let upcomingEventBufferWidth: CGFloat = 32.5
|
|
|
|
|
|
|
|
protocol StatusItemViewConforming {
|
|
|
|
/// Mark that we need to refresh the text we're showing in the menubar
|
|
|
|
func statusItemViewSetNeedsDisplay()
|
|
|
|
|
|
|
|
/// Status Item Views can be used to represent different information (like time in location, or an upcoming meeting). Distinguish between different status items view through this identifier
|
|
|
|
func statusItemViewIdentifier() -> String
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Observe for User Default changes for timezones in App Delegate and reconstruct the Status View if neccesary
|
|
|
|
/// We'll inject the menubar timezones into Status Container View which'll pass it to StatusItemView
|
|
|
|
/// The benefit of doing so is reducing time-spent calculating menubar timezones and deserialization through `TimezoneData.customObject`
|
|
|
|
/// Also inject, `shouldDisplaySecondsInMenubar`
|
|
|
|
///
|
|
|
|
|
|
|
|
class StatusContainerView: NSView {
|
|
|
|
private var previousX: Int = 0
|
|
|
|
private let store: DataStore
|
|
|
|
private lazy var paragraphStyle: NSMutableParagraphStyle = {
|
|
|
|
let paragraphStyle = NSMutableParagraphStyle()
|
|
|
|
paragraphStyle.alignment = .center
|
|
|
|
paragraphStyle.lineBreakMode = .byTruncatingTail
|
|
|
|
// Better readability for p,q,y,g in the status bar.
|
|
|
|
let userPreferredLanguage = Locale.preferredLanguages.first ?? "en-US"
|
|
|
|
let lineHeight = userPreferredLanguage.contains("en") ? 0.92 : 1
|
|
|
|
paragraphStyle.lineHeightMultiple = CGFloat(lineHeight)
|
|
|
|
return paragraphStyle
|
|
|
|
}()
|
|
|
|
|
|
|
|
override func awakeFromNib() {
|
|
|
|
super.awakeFromNib()
|
|
|
|
wantsLayer = true
|
|
|
|
layer?.backgroundColor = NSColor.clear.cgColor
|
|
|
|
}
|
|
|
|
|
|
|
|
init(with timezones: [Data],
|
|
|
|
store: DataStore,
|
|
|
|
showUpcomingEventView: Bool,
|
|
|
|
bufferContainerWidth: Int)
|
|
|
|
{
|
|
|
|
self.store = store
|
|
|
|
|
|
|
|
func addSubviews() {
|
|
|
|
if showUpcomingEventView,
|
|
|
|
let events = EventCenter.sharedCenter().eventsForDate[NSCalendar.autoupdatingCurrent.startOfDay(for: Date())],
|
|
|
|
events.isEmpty == false,
|
|
|
|
let upcomingEvent = EventCenter.sharedCenter().nextOccuring(events)
|
|
|
|
{
|
|
|
|
let calculatedWidth = bestWidth(for: upcomingEvent)
|
|
|
|
let frame = NSRect(x: previousX, y: 0, width: calculatedWidth, height: 30)
|
|
|
|
let calendarItemView = UpcomingEventStatusItemView(frame: frame)
|
|
|
|
calendarItemView.dataObject = upcomingEvent
|
|
|
|
addSubview(calendarItemView)
|
|
|
|
previousX += calculatedWidth
|
|
|
|
}
|
|
|
|
|
|
|
|
timezones.forEach {
|
|
|
|
if let timezoneObject = TimezoneData.customObject(from: $0) {
|
|
|
|
addTimezone(timezoneObject)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
let timeBasedAttributes = [
|
|
|
|
NSAttributedString.Key.font: compactModeTimeFont,
|
|
|
|
NSAttributedString.Key.backgroundColor: NSColor.clear,
|
|
|
|
NSAttributedString.Key.paragraphStyle: defaultParagraphStyle,
|
|
|
|
]
|
|
|
|
|
|
|
|
func containerWidth(for timezones: [Data], event: EventInfo?) -> CGFloat {
|
|
|
|
var compressedWidth = timezones.reduce(0.0) { result, timezone -> CGFloat in
|
|
|
|
|
|
|
|
if let timezoneObject = TimezoneData.customObject(from: timezone) {
|
|
|
|
let precalculatedWidth = Double(compactWidth(for: timezoneObject, with: store))
|
|
|
|
let operationObject = TimezoneDataOperations(with: timezoneObject, store: store)
|
|
|
|
let calculatedSubtitleSize = compactModeTimeFont.size(for: operationObject.compactMenuSubtitle(),
|
|
|
|
width: precalculatedWidth,
|
|
|
|
attributes: timeBasedAttributes)
|
|
|
|
let calculatedTitleSize = compactModeTimeFont.size(for: operationObject.compactMenuTitle(),
|
|
|
|
width: precalculatedWidth,
|
|
|
|
attributes: timeBasedAttributes)
|
|
|
|
let showSeconds = timezoneObject.shouldShowSeconds(store.timezoneFormat())
|
|
|
|
let secondsBuffer: CGFloat = showSeconds ? 7 : 0
|
|
|
|
return result + max(calculatedTitleSize.width, calculatedSubtitleSize.width) + bufferWidth + secondsBuffer
|
|
|
|
}
|
|
|
|
|
|
|
|
return result + CGFloat(bufferContainerWidth)
|
|
|
|
}
|
|
|
|
|
|
|
|
if showUpcomingEventView {
|
|
|
|
let calculateMeetingHeaderSize = compactModeTimeFont.size(for: upcomingEvent?.event.title ?? "", width: 70, attributes: timeBasedAttributes)
|
|
|
|
let calculatedMeetingSubtitleSize = compactModeTimeFont.size(for: upcomingEvent?.metadataForMeeting() ?? "", width: 55, attributes: timeBasedAttributes)
|
|
|
|
compressedWidth += CGFloat(min(calculateMeetingHeaderSize.width, calculatedMeetingSubtitleSize.width) + bufferWidth + upcomingEventBufferWidth)
|
|
|
|
}
|
|
|
|
|
|
|
|
let calculatedWidth = min(compressedWidth,
|
|
|
|
CGFloat(timezones.count * bufferContainerWidth))
|
|
|
|
return calculatedWidth
|
|
|
|
}
|
|
|
|
|
|
|
|
let events = EventCenter.sharedCenter().eventsForDate[NSCalendar.autoupdatingCurrent.startOfDay(for: Date())]
|
|
|
|
let upcomingEvent = EventCenter.sharedCenter().nextOccuring(events ?? [])
|
|
|
|
|
|
|
|
let statusItemWidth = containerWidth(for: timezones, event: upcomingEvent)
|
|
|
|
let frame = NSRect(x: 0, y: 0, width: statusItemWidth, height: 30)
|
|
|
|
super.init(frame: frame)
|
|
|
|
|
|
|
|
addSubviews()
|
|
|
|
}
|
|
|
|
|
|
|
|
@available(*, unavailable)
|
|
|
|
required init?(coder _: NSCoder) {
|
|
|
|
fatalError("init(coder:) has not been implemented")
|
|
|
|
}
|
|
|
|
|
|
|
|
func addTimezone(_ timezone: TimezoneData) {
|
|
|
|
let calculatedWidth = bestWidth(for: timezone)
|
|
|
|
let frame = NSRect(x: previousX, y: 0, width: calculatedWidth, height: 30)
|
|
|
|
|
|
|
|
let statusItemView = StatusItemView(frame: frame)
|
|
|
|
statusItemView.dataObject = timezone
|
|
|
|
|
|
|
|
addSubview(statusItemView)
|
|
|
|
|
|
|
|
previousX += calculatedWidth
|
|
|
|
}
|
|
|
|
|
|
|
|
private func bestWidth(for timezone: TimezoneData) -> Int {
|
|
|
|
var textColor = hasDarkAppearance ? NSColor.white : NSColor.black
|
|
|
|
|
|
|
|
if #available(OSX 11.0, *) {
|
|
|
|
textColor = NSColor.white
|
|
|
|
}
|
|
|
|
|
|
|
|
let timeBasedAttributes = [
|
|
|
|
NSAttributedString.Key.font: compactModeTimeFont,
|
|
|
|
NSAttributedString.Key.foregroundColor: textColor,
|
|
|
|
NSAttributedString.Key.backgroundColor: NSColor.clear,
|
|
|
|
NSAttributedString.Key.paragraphStyle: paragraphStyle,
|
|
|
|
]
|
|
|
|
|
|
|
|
let operation = TimezoneDataOperations(with: timezone, store: store)
|
|
|
|
let bestSize = compactModeTimeFont.size(for: operation.compactMenuSubtitle(),
|
|
|
|
width: Double(compactWidth(for: timezone, with: store)),
|
|
|
|
attributes: timeBasedAttributes)
|
|
|
|
let bestTitleSize = compactModeTimeFont.size(for: operation.compactMenuTitle(),
|
|
|
|
width: Double(compactWidth(for: timezone, with: store)),
|
|
|
|
attributes: timeBasedAttributes)
|
|
|
|
|
|
|
|
return Int(max(bestSize.width, bestTitleSize.width) + bufferWidth)
|
|
|
|
}
|
|
|
|
|
|
|
|
private func bestWidth(for eventInfo: EventInfo) -> Int {
|
|
|
|
var textColor = hasDarkAppearance ? NSColor.white : NSColor.black
|
|
|
|
|
|
|
|
if #available(OSX 11.0, *) {
|
|
|
|
textColor = NSColor.white
|
|
|
|
}
|
|
|
|
|
|
|
|
let timeBasedAttributes = [
|
|
|
|
NSAttributedString.Key.font: compactModeTimeFont,
|
|
|
|
NSAttributedString.Key.foregroundColor: textColor,
|
|
|
|
NSAttributedString.Key.backgroundColor: NSColor.clear,
|
|
|
|
NSAttributedString.Key.paragraphStyle: defaultParagraphStyle,
|
|
|
|
]
|
|
|
|
|
|
|
|
let bestSize = compactModeTimeFont.size(for: eventInfo.metadataForMeeting(),
|
|
|
|
width: 55, // Default for a location based status view
|
|
|
|
attributes: timeBasedAttributes)
|
|
|
|
let bestTitleSize = compactModeTimeFont.size(for: eventInfo.event.title,
|
|
|
|
width: 70, // Little more buffer since meeting titles tend to be longer
|
|
|
|
attributes: timeBasedAttributes)
|
|
|
|
|
|
|
|
return Int(max(bestSize.width, bestTitleSize.width) + bufferWidth)
|
|
|
|
}
|
|
|
|
|
|
|
|
func updateTime() {
|
|
|
|
if subviews.isEmpty {
|
|
|
|
assertionFailure("Subviews count should > 0")
|
|
|
|
}
|
|
|
|
|
|
|
|
for view in subviews {
|
|
|
|
if let conformingView = view as? StatusItemViewConforming {
|
|
|
|
conformingView.statusItemViewSetNeedsDisplay()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// See if frame's width needs any adjustment
|
|
|
|
adjustWidthIfNeccessary()
|
|
|
|
}
|
|
|
|
|
|
|
|
private func adjustWidthIfNeccessary() {
|
|
|
|
var newWidth: CGFloat = 0
|
|
|
|
|
|
|
|
subviews.forEach {
|
|
|
|
if let statusItem = $0 as? StatusItemView, statusItem.isHidden == false {
|
|
|
|
// Determine what's the best width required to display the current string.
|
|
|
|
let newBestWidth = CGFloat(bestWidth(for: statusItem.dataObject))
|
|
|
|
|
|
|
|
// Let's note if the current width is too small/correct
|
|
|
|
newWidth += statusItem.frame.size.width != newBestWidth ? newBestWidth : statusItem.frame.size.width
|
|
|
|
|
|
|
|
statusItem.frame = CGRect(x: statusItem.frame.origin.x,
|
|
|
|
y: statusItem.frame.origin.y,
|
|
|
|
width: newBestWidth,
|
|
|
|
height: statusItem.frame.size.height)
|
|
|
|
} else if let upcomingEventView = $0 as? UpcomingEventStatusItemView, upcomingEventView.isHidden == false {
|
|
|
|
let newBestWidth = CGFloat(bestWidth(for: upcomingEventView.dataObject))
|
|
|
|
|
|
|
|
// Let's note if the current width is too small/correct
|
|
|
|
newWidth += $0.frame.size.width != newBestWidth ? newBestWidth : upcomingEventView.frame.size.width
|
|
|
|
|
|
|
|
upcomingEventView.frame = CGRect(x: upcomingEventView.frame.origin.x,
|
|
|
|
y: upcomingEventView.frame.origin.y,
|
|
|
|
width: newBestWidth,
|
|
|
|
height: upcomingEventView.frame.size.height)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if newWidth != frame.size.width, newWidth > frame.size.width + 2.0 {
|
|
|
|
Logger.info("Correcting our width to \(newWidth) and the previous width was \(frame.size.width)")
|
|
|
|
// NSView move animation
|
|
|
|
NSAnimationContext.runAnimationGroup({ context in
|
|
|
|
context.duration = 0.2
|
|
|
|
context.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn)
|
|
|
|
let newFrame = CGRect(x: frame.origin.x, y: frame.origin.y, width: newWidth, height: frame.size.height)
|
|
|
|
// The view will animate to the new origin
|
|
|
|
self.animator().frame = newFrame
|
|
|
|
}) {}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|