|
|
|
// Copyright © 2015 Abhishek Banthia
|
|
|
|
|
|
|
|
import Cocoa
|
|
|
|
import CoreLoggerKit
|
|
|
|
import CoreModelKit
|
|
|
|
|
|
|
|
private enum MenubarState {
|
|
|
|
case compactText
|
|
|
|
case standardText
|
|
|
|
case icon
|
|
|
|
}
|
|
|
|
|
|
|
|
class StatusItemHandler: NSObject {
|
|
|
|
var hasActiveIcon: Bool = false
|
|
|
|
|
|
|
|
var menubarTimer: Timer?
|
|
|
|
|
|
|
|
var statusItem: NSStatusItem = {
|
|
|
|
let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
|
|
|
statusItem.button?.toolTip = "Clocker"
|
|
|
|
(statusItem.button?.cell as? NSButtonCell)?.highlightsBy = NSCell.StyleMask(rawValue: 0)
|
|
|
|
return statusItem
|
|
|
|
}()
|
|
|
|
|
|
|
|
private lazy var menubarTitleHandler = MenubarTitleProvider(with: self.store, eventStore: EventCenter.sharedCenter())
|
|
|
|
|
|
|
|
private var statusContainerView: StatusContainerView?
|
|
|
|
|
|
|
|
private var nsCalendar = Calendar.autoupdatingCurrent
|
|
|
|
|
|
|
|
private lazy var units: Set<Calendar.Component> = Set([.era, .year, .month, .day, .hour, .minute])
|
|
|
|
|
|
|
|
private var userNotificationsDidChangeNotif: NSObjectProtocol?
|
|
|
|
|
|
|
|
private let store: DataStore
|
|
|
|
|
|
|
|
// Current State might be set twice when the user first launches an app.
|
|
|
|
// First, when StatusItemHandler() is instantiated in AppDelegate
|
|
|
|
// Second, when AppDelegate.fetchLocalTimezone() is called triggering a customLabel didSet.
|
|
|
|
// TODO: Make sure it's set just once.
|
|
|
|
private var currentState: MenubarState = .standardText {
|
|
|
|
didSet {
|
|
|
|
// Do some cleanup
|
|
|
|
switch oldValue {
|
|
|
|
case .compactText:
|
|
|
|
statusItem.button?.subviews = []
|
|
|
|
statusContainerView = nil
|
|
|
|
case .standardText:
|
|
|
|
statusItem.button?.title = UserDefaultKeys.emptyString
|
|
|
|
case .icon:
|
|
|
|
statusItem.button?.image = nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// Now setup for the new menubar state
|
|
|
|
switch currentState {
|
|
|
|
case .compactText:
|
|
|
|
setupForCompactTextMode()
|
|
|
|
case .standardText:
|
|
|
|
setupForStandardTextMode()
|
|
|
|
case .icon:
|
|
|
|
setClockerIcon()
|
|
|
|
}
|
|
|
|
|
|
|
|
Logger.info("Status Bar Current State changed: \(currentState)\n")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
init(with dataStore: DataStore) {
|
|
|
|
store = dataStore
|
|
|
|
super.init()
|
|
|
|
|
|
|
|
setupStatusItem()
|
|
|
|
setupNotificationObservers()
|
|
|
|
}
|
|
|
|
|
|
|
|
func setupStatusItem() {
|
|
|
|
// Let's figure out the initial menubar state
|
|
|
|
var menubarState = MenubarState.icon
|
|
|
|
|
|
|
|
let shouldTextBeDisplayed = store.menubarTimezones()?.isEmpty ?? true
|
|
|
|
|
|
|
|
if !shouldTextBeDisplayed || store.shouldDisplay(.showMeetingInMenubar) {
|
|
|
|
if store.shouldDisplay(.menubarCompactMode) {
|
|
|
|
menubarState = .compactText
|
|
|
|
} else {
|
|
|
|
menubarState = .standardText
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Initial state has been figured out. Time to set it!
|
|
|
|
currentState = menubarState
|
|
|
|
|
|
|
|
func setSelector() {
|
|
|
|
statusItem.button?.action = #selector(menubarIconClicked(_:))
|
|
|
|
}
|
|
|
|
|
|
|
|
statusItem.button?.target = self
|
|
|
|
statusItem.autosaveName = NSStatusItem.AutosaveName("ClockerStatusItem")
|
|
|
|
setSelector()
|
|
|
|
}
|
|
|
|
|
|
|
|
private func setupNotificationObservers() {
|
|
|
|
let center = NotificationCenter.default
|
|
|
|
let mainQueue = OperationQueue.main
|
|
|
|
|
|
|
|
center.addObserver(self,
|
|
|
|
selector: #selector(updateMenubar),
|
|
|
|
name: NSWorkspace.didWakeNotification,
|
|
|
|
object: nil)
|
|
|
|
|
|
|
|
DistributedNotificationCenter.default.addObserver(self, selector: #selector(respondToInterfaceStyleChange),
|
|
|
|
name: .interfaceStyleDidChange,
|
|
|
|
object: nil)
|
|
|
|
|
|
|
|
userNotificationsDidChangeNotif = center.addObserver(forName: UserDefaults.didChangeNotification,
|
|
|
|
object: self,
|
|
|
|
queue: mainQueue)
|
|
|
|
{ _ in
|
|
|
|
self.setupStatusItem()
|
|
|
|
}
|
|
|
|
|
|
|
|
NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.willSleepNotification, object: nil, queue: OperationQueue.main) { _ in
|
|
|
|
self.menubarTimer?.invalidate()
|
|
|
|
}
|
|
|
|
|
|
|
|
NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.didWakeNotification, object: nil, queue: OperationQueue.main) { _ in
|
|
|
|
self.setupStatusItem()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
deinit {
|
|
|
|
if let userNotifsDidChange = userNotificationsDidChangeNotif {
|
|
|
|
NotificationCenter.default.removeObserver(userNotifsDidChange)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private func constructCompactView(with upcomingEventView: Bool = false) {
|
|
|
|
statusItem.button?.subviews = []
|
|
|
|
statusContainerView = nil
|
|
|
|
|
|
|
|
let menubarTimezones = store.menubarTimezones() ?? []
|
|
|
|
if menubarTimezones.isEmpty {
|
|
|
|
currentState = .icon
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
statusContainerView = StatusContainerView(with: menubarTimezones,
|
|
|
|
store: store,
|
|
|
|
showUpcomingEventView: upcomingEventView,
|
|
|
|
bufferContainerWidth: bufferCalculatedWidth())
|
|
|
|
statusContainerView?.wantsLayer = true
|
|
|
|
statusItem.button?.addSubview(statusContainerView!)
|
|
|
|
statusItem.button?.frame = statusContainerView!.bounds
|
|
|
|
|
|
|
|
// For OS < 11, we need to fix the sizing (width) on the button's window
|
|
|
|
// Otherwise, we won't be able to see the menu bar option at all.
|
|
|
|
if let window = statusItem.button?.window {
|
|
|
|
let currentFrame = window.frame
|
|
|
|
let newFrame = NSRect(x: currentFrame.origin.x,
|
|
|
|
y: currentFrame.origin.y,
|
|
|
|
width: statusItem.button?.bounds.size.width ?? 0,
|
|
|
|
height: currentFrame.size.height)
|
|
|
|
window.setFrame(newFrame, display: true)
|
|
|
|
}
|
|
|
|
statusItem.button?.subviews.first?.window?.backgroundColor = NSColor.clear
|
|
|
|
}
|
|
|
|
|
|
|
|
// This is called when the Apple interface style pre-Mojave is changed.
|
|
|
|
// In High Sierra and before, we could have a dark or light menubar and dock
|
|
|
|
// Our icon is template, so it changes automatically; so is our standard status bar text
|
|
|
|
// Only need to handle the compact mode!
|
|
|
|
@objc func respondToInterfaceStyleChange() {
|
|
|
|
if store.shouldDisplay(.menubarCompactMode) {
|
|
|
|
updateCompactMenubar()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@objc func setHasActiveIcon(_ value: Bool) {
|
|
|
|
hasActiveIcon = value
|
|
|
|
}
|
|
|
|
|
|
|
|
@objc func menubarIconClicked(_ sender: NSStatusBarButton) {
|
|
|
|
guard let mainDelegate = NSApplication.shared.delegate as? AppDelegate else {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
mainDelegate.togglePanel(sender)
|
|
|
|
}
|
|
|
|
|
|
|
|
@objc func updateMenubar() {
|
|
|
|
guard let fireDate = calculateFireDate() else { return }
|
|
|
|
|
|
|
|
let shouldDisplaySeconds = shouldDisplaySecondsInMenubar()
|
|
|
|
|
|
|
|
menubarTimer = Timer(fire: fireDate,
|
|
|
|
interval: 0,
|
|
|
|
repeats: false,
|
|
|
|
block: { [weak self] _ in
|
|
|
|
|
|
|
|
if let strongSelf = self {
|
|
|
|
strongSelf.refresh()
|
|
|
|
}
|
|
|
|
})
|
|
|
|
|
|
|
|
// Tolerance, even a small amount, has a positive imapct on the power usage. As a rule, we set it to 10% of the interval
|
|
|
|
menubarTimer?.tolerance = shouldDisplaySeconds ? 0.5 : 20
|
|
|
|
|
|
|
|
guard let runLoopTimer = menubarTimer else {
|
|
|
|
Logger.info("Timer is unexpectedly nil")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
RunLoop.main.add(runLoopTimer, forMode: .common)
|
|
|
|
}
|
|
|
|
|
|
|
|
private func shouldDisplaySecondsInMenubar() -> Bool {
|
|
|
|
let syncedTimezones = store.menubarTimezones() ?? []
|
|
|
|
|
|
|
|
let timezonesSupportingSeconds = syncedTimezones.filter { data in
|
|
|
|
if let timezoneObj = TimezoneData.customObject(from: data) {
|
|
|
|
return timezoneObj.shouldShowSeconds(store.timezoneFormat())
|
|
|
|
}
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
|
|
|
return timezonesSupportingSeconds.isEmpty == false
|
|
|
|
}
|
|
|
|
|
|
|
|
private func calculateFireDate() -> Date? {
|
|
|
|
let shouldDisplaySeconds = shouldDisplaySecondsInMenubar()
|
|
|
|
let menubarFavourites = store.menubarTimezones()
|
|
|
|
|
|
|
|
if !units.contains(.second), shouldDisplaySeconds {
|
|
|
|
units.insert(.second)
|
|
|
|
}
|
|
|
|
|
|
|
|
var components = nsCalendar.dateComponents(units, from: Date())
|
|
|
|
|
|
|
|
// We want to update every second only when there's a timezone present!
|
|
|
|
if shouldDisplaySeconds, let seconds = components.second, let favourites = menubarFavourites, !favourites.isEmpty {
|
|
|
|
components.second = seconds + 1
|
|
|
|
} else if let minutes = components.minute {
|
|
|
|
components.minute = minutes + 1
|
|
|
|
} else {
|
|
|
|
Logger.info("Unable to create date components for the menubar timewr")
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
guard let fireDate = nsCalendar.date(from: components) else {
|
|
|
|
Logger.info("Unable to form Fire Date")
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
return fireDate
|
|
|
|
}
|
|
|
|
|
|
|
|
func updateCompactMenubar() {
|
|
|
|
let filteredEvents = EventCenter.sharedCenter().filteredEvents
|
|
|
|
let calendar = EventCenter.sharedCenter().autoupdatingCalendar
|
|
|
|
let upcomingEvent = menubarTitleHandler.checkForUpcomingEvents(filteredEvents, calendar: calendar)
|
|
|
|
if upcomingEvent != nil {
|
|
|
|
// Iterate and see if we're showing the calendar item view
|
|
|
|
let upcomingEventView = retrieveUpcomingEventStatusView()
|
|
|
|
// If not, reconstruct Status Container View with another view
|
|
|
|
if upcomingEventView == nil {
|
|
|
|
constructCompactView(with: true)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if let upcomingEventView = retrieveUpcomingEventStatusView(), upcomingEvent == nil {
|
|
|
|
upcomingEventView.removeFromSuperview()
|
|
|
|
constructCompactView() // So that Status Container View reclaims the space
|
|
|
|
}
|
|
|
|
// This will internally call `statusItemViewSetNeedsDisplay` on all subviews ensuring all text in the menubar is up-to-date.
|
|
|
|
statusContainerView?.updateTime()
|
|
|
|
}
|
|
|
|
|
|
|
|
private func removeUpcomingStatusItemView() {
|
|
|
|
NSAnimationContext.runAnimationGroup({ context in
|
|
|
|
context.duration = 0.2
|
|
|
|
let upcomingEventView = retrieveUpcomingEventStatusView()
|
|
|
|
upcomingEventView?.removeFromSuperview()
|
|
|
|
}) { [weak self] in
|
|
|
|
if let sSelf = self {
|
|
|
|
sSelf.constructCompactView()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func refresh() {
|
|
|
|
if currentState == .compactText {
|
|
|
|
updateCompactMenubar()
|
|
|
|
updateMenubar()
|
|
|
|
} else if currentState == .standardText, let title = menubarTitleHandler.titleForMenubar() {
|
|
|
|
// Need setting button's image to nil
|
|
|
|
// Especially if we have showUpcomingEvents turned to true and menubar timezones are empty
|
|
|
|
statusItem.button?.image = nil
|
|
|
|
let attributes = [NSAttributedString.Key.font: NSFont.monospacedDigitSystemFont(ofSize: 13.0, weight: NSFont.Weight.regular),
|
|
|
|
NSAttributedString.Key.baselineOffset: 0.1] as [NSAttributedString.Key: Any]
|
|
|
|
statusItem.button?.attributedTitle = NSAttributedString(string: title, attributes: attributes)
|
|
|
|
updateMenubar()
|
|
|
|
} else {
|
|
|
|
setClockerIcon()
|
|
|
|
menubarTimer?.invalidate()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private func setupForStandardTextMode() {
|
|
|
|
Logger.info("Initializing menubar timer")
|
|
|
|
|
|
|
|
// Let's invalidate the previous timer
|
|
|
|
menubarTimer?.invalidate()
|
|
|
|
menubarTimer = nil
|
|
|
|
|
|
|
|
setupForStandardText()
|
|
|
|
updateMenubar()
|
|
|
|
}
|
|
|
|
|
|
|
|
func invalidateTimer(showIcon show: Bool, isSyncing sync: Bool) {
|
|
|
|
// Check if user is not showing
|
|
|
|
// 1. Timezones
|
|
|
|
// 2. Upcoming Event
|
|
|
|
let menubarFavourites = store.menubarTimezones() ?? []
|
|
|
|
|
|
|
|
if menubarFavourites.isEmpty, store.shouldDisplay(.showMeetingInMenubar) == false {
|
|
|
|
Logger.info("Invalidating menubar timer!")
|
|
|
|
|
|
|
|
invalidation()
|
|
|
|
|
|
|
|
if show {
|
|
|
|
currentState = .icon
|
|
|
|
}
|
|
|
|
|
|
|
|
} else if sync {
|
|
|
|
Logger.info("Invalidating menubar timer for sync purposes!")
|
|
|
|
|
|
|
|
invalidation()
|
|
|
|
|
|
|
|
if show {
|
|
|
|
setClockerIcon()
|
|
|
|
}
|
|
|
|
|
|
|
|
} else {
|
|
|
|
Logger.info("Not stopping menubar timer!")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private func invalidation() {
|
|
|
|
menubarTimer?.invalidate()
|
|
|
|
}
|
|
|
|
|
|
|
|
private func setClockerIcon() {
|
|
|
|
if statusItem.button?.subviews.isEmpty == false {
|
|
|
|
statusItem.button?.subviews = []
|
|
|
|
}
|
|
|
|
|
|
|
|
if statusItem.button?.image?.name() == NSImage.Name.menubarIcon {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
statusItem.button?.title = UserDefaultKeys.emptyString
|
|
|
|
statusItem.button?.image = NSImage(named: .menubarIcon)
|
|
|
|
statusItem.button?.imagePosition = .imageOnly
|
|
|
|
statusItem.button?.toolTip = "Clocker"
|
|
|
|
}
|
|
|
|
|
|
|
|
private func setupForStandardText() {
|
|
|
|
var menubarText = UserDefaultKeys.emptyString
|
|
|
|
|
|
|
|
if let menubarTitle = menubarTitleHandler.titleForMenubar() {
|
|
|
|
menubarText = menubarTitle
|
|
|
|
} else if store.shouldDisplay(.showMeetingInMenubar) {
|
|
|
|
// Don't have any meeting to show
|
|
|
|
} else {
|
|
|
|
// We have no favourites to display and no meetings to show.
|
|
|
|
// That means we should display our icon!
|
|
|
|
}
|
|
|
|
|
|
|
|
guard !menubarText.isEmpty else {
|
|
|
|
setClockerIcon()
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
let attributes = [NSAttributedString.Key.font: NSFont.monospacedDigitSystemFont(ofSize: 13.0, weight: NSFont.Weight.regular),
|
|
|
|
NSAttributedString.Key.baselineOffset: 0.1] as [NSAttributedString.Key: Any]
|
|
|
|
statusItem.button?.attributedTitle = NSAttributedString(string: menubarText, attributes: attributes)
|
|
|
|
statusItem.button?.image = nil
|
|
|
|
statusItem.button?.imagePosition = .imageLeft
|
|
|
|
}
|
|
|
|
|
|
|
|
private func setupForCompactTextMode() {
|
|
|
|
// Let's invalidate the previous timer
|
|
|
|
menubarTimer?.invalidate()
|
|
|
|
menubarTimer = nil
|
|
|
|
|
|
|
|
let filteredEvents = EventCenter.sharedCenter().filteredEvents
|
|
|
|
let calendar = EventCenter.sharedCenter().autoupdatingCalendar
|
|
|
|
let checkForUpcomingEvents = menubarTitleHandler.checkForUpcomingEvents(filteredEvents, calendar: calendar)
|
|
|
|
constructCompactView(with: checkForUpcomingEvents != nil)
|
|
|
|
updateMenubar()
|
|
|
|
}
|
|
|
|
|
|
|
|
private func retrieveUpcomingEventStatusView() -> NSView? {
|
|
|
|
let upcomingEventView = statusContainerView?.subviews.first(where: { statusItemView in
|
|
|
|
if let upcomingEventView = statusItemView as? StatusItemViewConforming {
|
|
|
|
return upcomingEventView.statusItemViewIdentifier() == "upcoming_event_view"
|
|
|
|
}
|
|
|
|
return false
|
|
|
|
})
|
|
|
|
return upcomingEventView
|
|
|
|
}
|
|
|
|
|
|
|
|
private func bufferCalculatedWidth() -> Int {
|
|
|
|
var totalWidth = 55
|
|
|
|
|
|
|
|
if store.shouldShowDayInMenubar() {
|
|
|
|
totalWidth += 12
|
|
|
|
}
|
|
|
|
|
|
|
|
if store.isBufferRequiredForTwelveHourFormats() {
|
|
|
|
totalWidth += 20
|
|
|
|
}
|
|
|
|
|
|
|
|
if store.shouldShowDateInMenubar() {
|
|
|
|
totalWidth += 20
|
|
|
|
}
|
|
|
|
|
|
|
|
if store.shouldDisplay(.showMeetingInMenubar) {
|
|
|
|
totalWidth += 100
|
|
|
|
}
|
|
|
|
|
|
|
|
return totalWidth
|
|
|
|
}
|
|
|
|
}
|