Sun, 05 Dec 2021 00:42:01 +0200
Need to time menu updates using Apple's frameworks to avoid segfaults.
However rumps.Timer doesn't work with menu updates while the menu is open, so
implement EventTrackingTimer class that uses NSEventTrackingRunLoopMode in
mainRunLoop.
borgend/ui.py | file | annotate | diff | comparison | revisions |
--- 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):