--- a/borgend/ui.py Fri Oct 30 14:09:39 2020 -0500 +++ b/borgend/ui.py Sun Dec 05 00:42:01 2021 +0200 @@ -10,7 +10,9 @@ import logging import objc import math -from threading import Lock, Timer +from threading import Lock +from Foundation import (NSDate, NSTimer, NSRunLoop, NSDefaultRunLoopMode) +from AppKit import (NSEventTrackingRunLoopMode) from . import backup from . import dreamtime @@ -48,8 +50,8 @@ def combine_state(a, b): return (max(a[0], b[0]), max(a[1], b[1])) -# Refresh the menu at most once a second to reduce flicker -refresh_interval=1.0 +# Refresh the menu at most twice a second +refresh_interval=0.5 # Workaround to rumps brokenness; # see https://github.com/jaredks/rumps/issues/59 @@ -185,20 +187,38 @@ return title, (status.state, status.errors), this_refresh_time +class EventTrackingTimer(rumps.Timer): + """ + Variant of rumps.Timer changed to + a) use *both* NSEventTrackingRunLoopMode (for when menu open) and + NSDefaultRunLoopMode (for when menu closed), + b) in the mainRunLoop, to allow creating timers there from other threads. + """ + def start(self): + """Start the timer thread loop.""" + if not self._status: + self._nsdate = NSDate.date() + self._nstimer = NSTimer.alloc().initWithFireDate_interval_target_selector_userInfo_repeats_( + self._nsdate, self._interval, self, 'callback:', None, True) + NSRunLoop.mainRunLoop().addTimer_forMode_(self._nstimer, NSEventTrackingRunLoopMode) + NSRunLoop.mainRunLoop().addTimer_forMode_(self._nstimer, NSDefaultRunLoopMode) + #rumps._TIMERS[self] = None + self._status = True + class BorgendTray(rumps.App): def __init__(self, backups): self.lock=Lock() self.backups=backups - self.refresh_timer=None - self.refresh_timer_time=None + self.refresh_timer=EventTrackingTimer(self.__refresh_callback, refresh_interval) + self.updated_recently=False self.statuses=[None]*len(backups) for index in range(len(backups)): self.statuses[index]=backups[index].status() - menu, title=self.build_menu_and_timer() + itemlist, title=self.build_menu_and_timer() - super().__init__(title, menu=menu, quit_button=None) + super().__init__(title, menu=itemlist, quit_button=None) for index in range(len(backups)): # Python closures suck dog's balls; hence the _index=index hack @@ -209,8 +229,12 @@ dreamtime.add_callback(self, self.__sleepwake_callback) - def __rebuild_menu(self): - menu=[] + def __rebuild_menu(self, update): + if not update: + self.itemlist=[] + + itemlist=self.itemlist + state=(backup.State.INACTIVE, backup.Errors.OK) refresh_time=None all_paused=True @@ -222,12 +246,23 @@ # first and the last program I write in Python until somebody # fixes this brain damage cbm=lambda sender, _b=b: self.__menu_select_backup(sender, _b) - item=rumps.MenuItem(title, callback=cbm) + itemstate=0 if not this_state[1].ok(): - item.state=-1 + itemstate=-1 elif this_state[0]==backup.State.SCHEDULED or this_state[0]==backup.State.QUEUED: - item.state=1 - menu.append(item) + itemstate=1 + + if update: + item=itemlist[index] + if item.title!=title: + item.title=title + if item.state!=itemstate: + item.state=itemstate + else: + item=rumps.MenuItem(title, callback=cbm) + item.state=itemstate + itemlist.append(item) + state=combine_state(state, this_state) all_paused=(all_paused and this_state[0]==backup.State.PAUSED) @@ -238,68 +273,76 @@ elif this_refresh_time: refresh_time=min(refresh_time, this_refresh_time) - menu_log=rumps.MenuItem("Show log", callback=lambda _: showlog()) - menu.append(menu_log) - + index = len(self.backups) if all_paused: pausename='Resume all' else: pausename="Pause all" - menu_pause=rumps.MenuItem(pausename, callback=lambda _: self.pause_resume_all()) - menu.append(menu_pause) + if update: + item=itemlist[index] + if item.title!=pausename: + item.title=pausename + else: + menu_pause=rumps.MenuItem(pausename, callback=lambda _: self.pause_resume_all()) + itemlist.append(menu_pause) - if not settings['no_quit_menu_entry']: - menu_quit=rumps.MenuItem("Quit...", callback=lambda _: self.quit()) - menu.append(menu_quit) - - return menu, state, refresh_time + if not update: + menu_log=rumps.MenuItem("Show log", callback=lambda _: showlog()) + itemlist.append(menu_log) + if not settings['no_quit_menu_entry']: + menu_quit=rumps.MenuItem("Quit...", callback=lambda _: self.quit()) + itemlist.append(menu_quit) + + return itemlist, state, refresh_time - def build_menu_and_timer(self): - if self.refresh_timer: - self.refresh_timer.cancel() - self.refresh_timer=None - self.refresh_timer_time=None + def build_menu_and_timer(self, update=False): + logger.debug('Rebuilding menu') + itemlist, state, refresh_time=self.__rebuild_menu(update) + title=trayname(state) + + if update and self.title!=title: + logger.debug("set title %s" % title) + self.title=title - logger.debug('Rebuilding menu') - menu, state, refresh_time=self.__rebuild_menu() - title=trayname(state) - - if refresh_time: + if not self.updated_recently and not refresh_time: + self.refresh_timer.stop() + elif self.updated_recently: + self.updated_recently=False + if self.refresh_timer.interval>refresh_interval: + self.refresh_timer.stop() + self.refresh_timer.interval=refresh_interval + self.refresh_timer.start() + else: # Need to time a refresh due to content display changing, # e.g., 'tomorrow' changing to a more specific hour. when=time.mktime(refresh_time.timetuple()) - delay=when-time.time() - if delay>0: - logger.debug('Timing menu refresh in %s seconds' % delay) - self.refresh_timer=Timer(delay, self.refresh_ui) - self.refresh_timer_time=refresh_time - self.refresh_timer.start() + delay=max(when-time.time(), refresh_interval) + logger.debug('Timing menu refresh in %s seconds' % delay) + self.refresh_timer.stop() + self.refresh_timer.interval=delay + self.refresh_timer.start() - return menu, title + return itemlist, title # Callbacks -- exception-protected to get any indications of errors @protect_noreturn - def refresh_ui(self): + def __refresh_callback(self, timer): with self.lock: - menu, title=self.build_menu_and_timer() - self.menu.clear() - self.menu.update(menu) - self.title=title - + self.build_menu_and_timer(True) + @protect_noreturn def __status_callback(self, index, status, errorlog=None): logger.debug("Tray status callback") with self.lock: self.statuses[index]=status + self.updated_recently=True # Time the refresh if it has not been timed, or if the timer # is timing for the "long-term" (refresh_timer_time set) - if not self.refresh_timer or self.refresh_timer_time: - # logger.debug("Timing refresh") - self.refresh_timer=Timer(refresh_interval, self.refresh_ui) - # refresh_timer_time is only set for "long-term timers" - self.refresh_timer_time=None - self.refresh_timer.start() + if self.refresh_timer.interval>refresh_interval: + self.refresh_timer.stop() + self.refresh_timer.interval=refresh_interval + self.refresh_timer.start() # if errorlog: # if 'msgid' not in errorlog or not isinstance(errorlog['msgid'], str):