@@ -563,6 +563,9 @@ class event_loop:
563563
564564 _current_instance = None
565565
566+ # The most the gate holds LVGL back after a slow pass (see _arm_gate).
567+ max_yield_ms = 100
568+
566569 def __init__ (
567570 self ,
568571 freq = None ,
@@ -571,6 +574,7 @@ def __init__(
571574 asynchronous = False ,
572575 exception_sink = None ,
573576 period_ms = None ,
577+ max_yield_ms = None ,
574578 ):
575579 """Create and register the LVGL event loop.
576580
@@ -583,6 +587,9 @@ def __init__(
583587 exception_sink: Callable receiving exceptions from task handling;
584588 defaults to :meth:`default_exception_sink`.
585589 period_ms: Explicit tick period in milliseconds (overrides ``freq``).
590+ max_yield_ms: Most the gate stays shut after a slow pass, leaving
591+ the thread to the application (default 100; see
592+ :meth:`_arm_gate`).
586593
587594 Raises:
588595 RuntimeError: Another loop is already running or async mode is
@@ -602,6 +609,8 @@ def __init__(
602609 self .delay = max (1 , 1000 // int (freq ))
603610 else :
604611 self .delay = LVGL_PERIOD_MS
612+ if max_yield_ms is not None :
613+ self .max_yield_ms = int (max_yield_ms )
605614
606615 self .refresh_cb = refresh_cb
607616 self .exception_sink = exception_sink if exception_sink else self .default_exception_sink
@@ -708,7 +717,7 @@ def _gate_allows(self):
708717 # Positive diff means _next_ok_ms is still in the future.
709718 return ticks_diff (self ._next_ok_ms , ticks_ms ()) <= 0
710719
711- def _arm_gate (self ):
720+ def _arm_gate (self , work_ms = 0 ):
712721 """Open the next slot one period after the last one, not after the work.
713722
714723 Pacing from *completion* silently halved the tick rate: the next timer
@@ -717,10 +726,31 @@ def _arm_gate(self):
717726 second tick was rejected no matter how fast the work was (measured 50/s
718727 on a 10 ms timer, ESP32-P4).
719728
720- Advancing from the previous slot keeps the cadence for fast frames. The
721- backlog protection the old comment was after is still there: if a slow
722- flush overran its slot, resynchronise to now instead of letting the
723- queued ticks fire back-to-back to catch up.
729+ Advancing from the previous slot keeps the cadence for fast frames.
730+
731+ The overrun branch is what decides how much of the thread the
732+ application gets, because on MicroPython the tick arrives through
733+ ``micropython.schedule`` and so runs between the application's own
734+ bytecodes. Resynchronising to *now* opened the gate at the instant the
735+ slow pass ended, leaving only the one tick period before the next one
736+ started: a 67.8 ms repaint against a 10 ms tick took ~87 % of the
737+ thread indefinitely, and a 300-iteration Python loop took 20 340 ms
738+ (lvgl-bindings#15). So after an overrun the gate stays shut for as
739+ long as the pass itself took -- a slow frame halves the frame rate
740+ instead of taking the thread. ``now + delay`` is not enough: the
741+ timer's own cadence already delivers the next tick a period later,
742+ which is exactly the sliver the application was already getting.
743+
744+ Fast frames never reach this branch, so their cadence is untouched.
745+
746+ The hold is capped at ``max_yield_ms``. A pass much longer than a
747+ repaint is almost always the application's own work, run from LVGL
748+ timers (list rows built, results drained), and there is no other
749+ application code waiting for the thread: an uncapped hold only left
750+ it idle for as long again, doubling every UI stall. On an
751+ ESP32-S3-Touch-LCD-7 a 440 ms pass was followed by ~400 ms of idle,
752+ on every screen change. The capped hold still covers the ~60 ms
753+ repaints lvgl-bindings#15 was about.
724754 """
725755 if ticks_ms is None or ticks_add is None or ticks_diff is None :
726756 return
@@ -730,9 +760,20 @@ def _arm_gate(self):
730760 return
731761 nxt = ticks_add (self ._next_ok_ms , self .delay )
732762 if ticks_diff (nxt , now ) < 0 :
733- nxt = now
763+ hold = min (work_ms , self .max_yield_ms )
764+ nxt = ticks_add (now , self .delay if hold < self .delay else hold )
734765 self ._next_ok_ms = nxt
735766
767+ def _run_and_arm (self , run ):
768+ """Run one pass and arm the gate with what that pass cost."""
769+ if ticks_ms is None or ticks_diff is None :
770+ run ()
771+ self ._arm_gate ()
772+ return
773+ start = ticks_ms ()
774+ run ()
775+ self ._arm_gate (ticks_diff (ticks_ms (), start ))
776+
736777 def timer_cb (self , t ):
737778 """Shared-timer callback: advance LVGL time and run/signal task handling.
738779
@@ -761,23 +802,26 @@ def timer_cb(self, t):
761802 self .refresh_event .set ()
762803 self ._arm_gate ()
763804 else :
764- self .task_handler ()
765- self ._arm_gate ()
805+ self ._run_and_arm (self .task_handler )
766806
767807 async def async_refresh (self ):
768808 """Asyncio task body: wait for refresh signals and run ``lv.task_handler``."""
769809 while True :
770810 await self .refresh_event .wait ()
771811 if _LV_NESTING is None or _LV_NESTING .value == 0 :
772812 self .refresh_event .clear ()
813+ start = ticks_ms () if ticks_ms is not None else None
773814 try :
774815 lv .task_handler ()
775816 except Exception as e :
776817 if self .exception_sink :
777818 self .exception_sink (e )
778819 if self .refresh_cb :
779820 self .refresh_cb ()
780- self ._arm_gate ()
821+ if start is None :
822+ self ._arm_gate ()
823+ else :
824+ self ._arm_gate (ticks_diff (ticks_ms (), start ))
781825
782826 def default_exception_sink (self , e ):
783827 """Print ``e`` with traceback to stderr (default :attr:`exception_sink`)."""
0 commit comments