@@ -456,7 +456,8 @@ def handle_exception(self, inst):
456456
457457from sys import platform
458458from os import environ
459- from functools import wraps , partial
459+ from functools import wraps , partial , update_wrapper
460+ from weakref import WeakKeyDictionary , ref as weakref_ref
460461from kivy .context import register_context
461462from kivy .config import Config
462463from kivy .logger import Logger
@@ -1094,62 +1095,227 @@ def callback_func(dt):
10941095 return delayed_func
10951096
10961097
1097- def triggered (timeout = 0 , interval = False ):
1098- '''Decorator that will trigger the call of the function at the specified
1099- timeout, through the method :meth:`CyClockBase.create_trigger`. Subsequent
1100- calls to the decorated function (while the timeout is active) are ignored.
1098+ # The @triggered decorator uses two classes to handle the duality of Python
1099+ # functions (global functions vs. instance methods):
1100+ #
1101+ # 1. _TriggeredWrapper (The Descriptor):
1102+ # - Lives at the module level (global) or class level.
1103+ # - For global functions: Manages its own `_trigger` and state in __call__.
1104+ # - For class methods: Implements the Descriptor Protocol (__get__) to
1105+ # intercept access via an instance (e.g., `widget.method()`).
1106+ # - Uses a WeakKeyDictionary to map instances to their respective
1107+ # _BoundTrigger objects, ensuring no memory leaks.
1108+ #
1109+ # 2. _BoundTrigger (The Instance State):
1110+ # - The object returned when accessing a method on an instance.
1111+ # - Maintains a `Clock.create_trigger` and argument buffers
1112+ # (`_args`, `_kwargs`) that are fully ISOLATED for each instance of the
1113+ # widget/object.
1114+ # - This isolation allows debouncing/throttling to work independently:
1115+ # triggering on 'Widget A' does not affect or cancel the schedule of
1116+ # 'Widget B'.
1117+ #
1118+ # This separation resolves "cross-talk" between instances while keeping
1119+ # the decorator flexible enough for use outside of classes.
1120+
1121+
1122+ class _TriggeredWrapper :
1123+ def __init__ (self , func , timeout , interval , debounce ):
1124+ self ._func = func
1125+ self ._timeout = timeout
1126+ self ._interval = interval
1127+ self ._debounce = debounce
1128+ self ._args = []
1129+ self ._kwargs = {}
1130+ self ._trigger = None
1131+ self ._instances = WeakKeyDictionary ()
1132+ update_wrapper (self , func , updated = ())
11011133
1102- It can be helpful when an expensive function (i.e. call to a server) can be
1103- triggered by different methods. Setting a proper timeout will delay the
1104- calling and only one of them will be triggered.
1134+ @property
1135+ def is_triggered (self ):
1136+ """Returns True if the trigger is currently scheduled."""
1137+ if self ._trigger is not None :
1138+ return self ._trigger .is_triggered
1139+ return False
1140+
1141+ def _ensure_trigger (self ):
1142+ if self ._trigger is None :
1143+ self ._trigger = Clock .create_trigger (
1144+ self .cb_function , timeout = self ._timeout , interval = self ._interval
1145+ )
1146+
1147+ def cb_function (self , dt ):
1148+ self ._func (* tuple (self ._args ), ** self ._kwargs )
1149+
1150+ def __call__ (self , * args , ** kwargs ):
1151+ self ._ensure_trigger ()
1152+ if self ._debounce :
1153+ self ._trigger .cancel ()
1154+ self ._args [:] = args
1155+ self ._kwargs .clear ()
1156+ self ._kwargs .update (kwargs )
1157+ self ._trigger ()
1158+
1159+ def cancel (self ):
1160+ if self ._trigger is not None :
1161+ self ._trigger .cancel ()
1162+
1163+ def __get__ (self , instance , owner ):
1164+ if instance is None :
1165+ return self
1166+
1167+ if instance not in self ._instances :
1168+ # Create a bound version for this instance
1169+ inst_weak = weakref_ref (instance )
1170+ inst_args = []
1171+ inst_kwargs = {}
1172+ func = self ._func
1173+ timeout = self ._timeout
1174+ interval = self ._interval
1175+ debounce = self ._debounce
1176+
1177+ def inst_cb (dt ):
1178+ inst = inst_weak ()
1179+ if inst is not None :
1180+ func (inst , * tuple (inst_args ), ** inst_kwargs )
1181+
1182+ inst_trigger = Clock .create_trigger (
1183+ inst_cb , timeout = timeout , interval = interval
1184+ )
1185+
1186+ bound_trigger = _BoundTrigger (
1187+ inst_trigger , inst_args , inst_kwargs , inst_weak , debounce
1188+ )
1189+ update_wrapper (bound_trigger , func )
1190+ self ._instances [instance ] = bound_trigger
1191+
1192+ return self ._instances [instance ]
1193+
1194+
1195+ class _BoundTrigger (object ):
1196+
1197+ def __init__ (self , trigger , args , kwargs , inst_weak , debounce ):
1198+ self ._trigger = trigger
1199+ self ._args = args
1200+ self ._kwargs = kwargs
1201+ self ._inst_weak = inst_weak
1202+ self ._debounce = debounce
1203+
1204+ def __call__ (self , * args , ** kwargs ):
1205+ if self ._inst_weak () is not None :
1206+ if self ._debounce :
1207+ self ._trigger .cancel ()
1208+ self ._args [:] = args
1209+ self ._kwargs .clear ()
1210+ self ._kwargs .update (kwargs )
1211+ self ._trigger ()
11051212
1106- @triggered(timeout, interval=False)
1107- def callback(id):
1108- print('The callback has been called with id=%d' % id)
1213+ @property
1214+ def is_triggered (self ):
1215+ """Returns True if the trigger is currently scheduled."""
1216+ return self ._trigger .is_triggered
11091217
1110- >> callback(id=1)
1111- >> callback(id=2)
1112- The callback has been called with id=2
1218+ def cancel ( self ):
1219+ """Unschedule the trigger."""
1220+ self . _trigger . cancel ()
11131221
1114- The decorated callback can also be unscheduled using:
11151222
1116- >> callback.cancel()
1223+ def triggered (timeout = 0 , interval = False , debounce = False ):
1224+ """Decorator that schedules the execution of a function after a specified
1225+ timeout using :meth:`CyClockBase.create_trigger`.
11171226
1118- .. versionadded:: 1.10.1
1119- '''
1120- fun = None
1227+ This decorator provides several key behaviors:
11211228
1122- if callable (timeout ):
1123- fun = timeout
1124- timeout = 0
1229+ * **Throttling** (default): Subsequent calls while a trigger is active are
1230+ ignored. The function fires once after the initial timeout.
1231+ * **Debouncing**: If ``debounce=True``, subsequent calls cancel any
1232+ pending execution and reschedule it. The function only fires after the
1233+ caller stops calling it for the duration of the ``timeout``.
1234+ * **Last Arguments Win**: If called multiple times before the trigger
1235+ fires, the arguments from the **latest** call are used.
1236+ * **Instance Isolation**: When used as a method decorator, each instance
1237+ gets its own isolated trigger and state.
1238+ * **Thread Safety**: Safe to call from external threads.
11251239
1126- def wrapper_triggered (func ):
1240+ The decorated function gains a ``.cancel()`` method to unschedule any
1241+ pending execution, and an ``.is_triggered`` property to check its status.
1242+
1243+ :param timeout: The delay (in seconds) before the function is called.
1244+ :type timeout: float, defaults to 0
1245+ :param interval: If True, the trigger will be repeating (standard Clock
1246+ interval behavior).
1247+ :type interval: bool, defaults to False
1248+ :param debounce: If True, subsequent calls will cancel and reschedule the
1249+ trigger.
1250+ :type debounce: bool, defaults to False
1251+
1252+ Example of a global function without debouncing::
1253+
1254+ @triggered(0.1)
1255+ def sync_data(user_id):
1256+ print(f"Syncing data for {user_id}")
1257+
1258+ sync_data(1)
1259+ sync_data(2) # Overwrites arguments of the first call
1260+ # 0.1s later: "Syncing data for 2"
11271261
1128- _args = []
1129- _kwargs = {}
1262+ Example of a global function with debouncing::
11301263
1131- def cb_function (dt ):
1132- func (* tuple (_args ), ** _kwargs )
1264+ @triggered(0.1, debounce=True)
1265+ def sync_data(user_id):
1266+ print(f"Syncing data for {user_id}")
11331267
1134- cb_trigger = Clock . create_trigger (
1135- cb_function ,
1136- timeout = timeout ,
1137- interval = interval )
1268+ sync_data(1)
1269+ # 0.05s later
1270+ sync_data(2) # Cancels the first call and reschedules for 0.1s from now
1271+ # 0.1s after the LAST call ("sync_data(2)"): "Syncing data for 2"
11381272
1139- @wraps (func )
1140- def trigger_function (* args , ** kwargs ):
1141- _args [:] = []
1142- _args .extend (list (args ))
1143- _kwargs .clear ()
1144- _kwargs .update (kwargs )
1145- cb_trigger ()
1273+ Example of an instance method::
11461274
1147- def trigger_cancel ():
1148- cb_trigger .cancel ()
1275+ class DataMapper:
1276+ @triggered(0.2)
1277+ def refresh(self, *args):
1278+ # This will be throttled per-instance
1279+ pass
11491280
1150- setattr (trigger_function , 'cancel' , trigger_cancel )
1281+ dm1, dm2 = DataMapper(), DataMapper()
1282+ dm1.refresh() # Scheduled
1283+ dm2.refresh() # Scheduled independently
11511284
1152- return trigger_function
1285+ Example with ``classmethod`` and ``staticmethod`` (always put
1286+ ``@triggered`` below them)::
1287+
1288+ class Utils:
1289+ @classmethod
1290+ @triggered(0.1)
1291+ def global_refresh(cls, *args):
1292+ # shared by all instances
1293+ pass
1294+
1295+ @staticmethod
1296+ @triggered(0.1, debounce=True)
1297+ def log_event(message):
1298+ pass
1299+
1300+ To cancel a pending call::
1301+
1302+ sync_data.cancel()
1303+
1304+ .. versionadded:: 1.10.1
1305+ .. versionchanged:: 3.0.0
1306+ Fixed behavior for instance methods to ensure state isolation between
1307+ different objects. Added ``debounce`` parameter.
1308+ """
1309+ fun = None
1310+
1311+ # handle both shorthand usage (@triggered)
1312+ # and parameterized usage (@triggered(0.1))
1313+ if callable (timeout ):
1314+ fun = timeout
1315+ timeout = 0
1316+
1317+ def wrapper_triggered (func ):
1318+ return _TriggeredWrapper (func , timeout , interval , debounce )
11531319
11541320 if fun is not None :
11551321 return wrapper_triggered (fun )
0 commit comments