borgend/ui.py

changeset 133
ec8014a2ee7a
parent 114
ad9fb3dd9fec
child 141
a1c97bc1789e
--- 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):

mercurial