瀏覽代碼

Support event listener through Hooks (#1993)

* Support event listener through Hooks
linked to taipy-enterprise 362

* use event listener in core elements

* refresh auth on init with state

* Make firing events non blocking and not in the current process (ie time for state to be updated ...)

* wait for the timer to execute

* Fab's comments

* manage events

* callable is not always callable

* manage callable instances

* Fab's comments

* sorting

---------

Co-authored-by: Fred Lefévère-Laoide <Fred.Lefevere-Laoide@Taipy.io>
Fred Lefévère-Laoide 7 月之前
父節點
當前提交
e82c6db393

+ 29 - 0
taipy/gui/_event_context_manager.py

@@ -0,0 +1,29 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.
+
+import typing as t
+from threading import Thread
+
+
+class _EventManager:
+    def __init__(self) -> None:
+        self.__thread_stack: t.List[Thread] = []
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        if self.__thread_stack:
+            self.__thread_stack.pop().start()
+        return self
+
+    def _add_thread(self, thread: Thread):
+        self.__thread_stack.append(thread)

+ 1 - 1
taipy/gui/_hook.py

@@ -42,7 +42,7 @@ class _Hooks(object, metaclass=_Singleton):
                     func = getattr(hook, name)
                     func = getattr(hook, name)
                     if not callable(func):
                     if not callable(func):
                         raise Exception(f"'{name}' hook is not callable")
                         raise Exception(f"'{name}' hook is not callable")
-                    res = getattr(hook, name)(*args, **kwargs)
+                    res = func(*args, **kwargs)
                 except Exception as e:
                 except Exception as e:
                     _TaipyLogger._get_logger().error(f"Error while calling hook '{name}': {e}")
                     _TaipyLogger._get_logger().error(f"Error while calling hook '{name}': {e}")
                     return
                     return

+ 23 - 19
taipy/gui/_renderers/builder.py

@@ -17,7 +17,7 @@ import typing as t
 import xml.etree.ElementTree as etree
 import xml.etree.ElementTree as etree
 from datetime import date, datetime, time
 from datetime import date, datetime, time
 from enum import Enum
 from enum import Enum
-from inspect import isclass, isroutine
+from inspect import isclass
 from types import LambdaType
 from types import LambdaType
 from urllib.parse import quote
 from urllib.parse import quote
 
 
@@ -34,7 +34,9 @@ from ..utils import (
     _getscopeattr,
     _getscopeattr,
     _getscopeattr_drill,
     _getscopeattr_drill,
     _is_boolean,
     _is_boolean,
+    _is_function,
     _is_true,
     _is_true,
+    _is_unnamed_function,
     _MapDict,
     _MapDict,
     _to_camel_case,
     _to_camel_case,
 )
 )
@@ -140,7 +142,7 @@ class _Builder:
             hash_value = gui._evaluate_expr(value)
             hash_value = gui._evaluate_expr(value)
             try:
             try:
                 func = gui._get_user_function(hash_value)
                 func = gui._get_user_function(hash_value)
-                if isroutine(func):
+                if _is_function(func):
                     return (func, hash_value)
                     return (func, hash_value)
                 return (_getscopeattr_drill(gui, hash_value), hash_value)
                 return (_getscopeattr_drill(gui, hash_value), hash_value)
             except AttributeError:
             except AttributeError:
@@ -163,10 +165,10 @@ class _Builder:
                     (val, hash_name) = _Builder.__parse_attribute_value(gui, v.replace('\\"', '"'))
                     (val, hash_name) = _Builder.__parse_attribute_value(gui, v.replace('\\"', '"'))
                 else:
                 else:
                     val = v
                     val = v
-                if isroutine(val) and not hash_name:
+                if _is_function(val) and not hash_name:
                     # if it's not a callable (and not a string), forget it
                     # if it's not a callable (and not a string), forget it
-                    if val.__name__ == "<lambda>":
-                        # if it is a lambda and it has already a hash_name, we're fine
+                    if _is_unnamed_function(val):
+                        # lambda or callable instance
                         hash_name = _get_lambda_id(t.cast(LambdaType, val))
                         hash_name = _get_lambda_id(t.cast(LambdaType, val))
                         gui._bind_var_val(hash_name, val)  # type: ignore[arg-type]
                         gui._bind_var_val(hash_name, val)  # type: ignore[arg-type]
                     else:
                     else:
@@ -294,7 +296,7 @@ class _Builder:
             except ValueError:
             except ValueError:
                 raise ValueError(f"Property {name} expects a number for control {self.__control_type}") from None
                 raise ValueError(f"Property {name} expects a number for control {self.__control_type}") from None
         elif isinstance(value, numbers.Number):
         elif isinstance(value, numbers.Number):
-            val = value  # type: ignore
+            val = value # type: ignore[assignment]
         else:
         else:
             raise ValueError(
             raise ValueError(
                 f"Property {name} expects a number for control {self.__control_type}, received {type(value)}"
                 f"Property {name} expects a number for control {self.__control_type}, received {type(value)}"
@@ -347,7 +349,7 @@ class _Builder:
             if not optional:
             if not optional:
                 _warn(f"Property {name} is required for control {self.__control_type}.")
                 _warn(f"Property {name} is required for control {self.__control_type}.")
             return self
             return self
-        elif isroutine(str_attr):
+        elif _is_function(str_attr):
             str_attr = self.__hashes.get(name)
             str_attr = self.__hashes.get(name)
             if str_attr is None:
             if str_attr is None:
                 return self
                 return self
@@ -403,12 +405,12 @@ class _Builder:
         adapter = self.__attributes.get("adapter", adapter)
         adapter = self.__attributes.get("adapter", adapter)
         if adapter and isinstance(adapter, str):
         if adapter and isinstance(adapter, str):
             adapter = self.__gui._get_user_function(adapter)
             adapter = self.__gui._get_user_function(adapter)
-        if adapter and not callable(adapter):
+        if adapter and not _is_function(adapter):
             _warn(f"{self.__element_name}: adapter property value is invalid.")
             _warn(f"{self.__element_name}: adapter property value is invalid.")
             adapter = None
             adapter = None
         var_type = self.__attributes.get("type", var_type)
         var_type = self.__attributes.get("type", var_type)
         if isclass(var_type):
         if isclass(var_type):
-            var_type = var_type.__name__  # type: ignore
+            var_type = var_type.__name__
 
 
         if isinstance(lov, list):
         if isinstance(lov, list):
             if not isinstance(var_type, str):
             if not isinstance(var_type, str):
@@ -425,10 +427,10 @@ class _Builder:
                 var_type = self.__gui._get_unique_type_adapter(type(elt).__name__)
                 var_type = self.__gui._get_unique_type_adapter(type(elt).__name__)
             if adapter is None:
             if adapter is None:
                 adapter = self.__gui._get_adapter_for_type(var_type)
                 adapter = self.__gui._get_adapter_for_type(var_type)
-            elif var_type == str.__name__ and isroutine(adapter):
+            elif var_type == str.__name__ and _is_function(adapter):
                 var_type += (
                 var_type += (
                     _get_lambda_id(t.cast(LambdaType, adapter))
                     _get_lambda_id(t.cast(LambdaType, adapter))
-                    if adapter.__name__ == "<lambda>"
+                    if _is_unnamed_function(adapter)
                     else _get_expr_var_name(adapter.__name__)
                     else _get_expr_var_name(adapter.__name__)
                 )
                 )
             if lov_name:
             if lov_name:
@@ -442,13 +444,15 @@ class _Builder:
                 else:
                 else:
                     self.__gui._add_type_for_var(value_name, t.cast(str, var_type))
                     self.__gui._add_type_for_var(value_name, t.cast(str, var_type))
             if adapter is not None:
             if adapter is not None:
-                self.__gui._add_adapter_for_type(var_type, adapter)  # type: ignore
+                self.__gui._add_adapter_for_type(var_type, adapter) # type: ignore[arg-type]
 
 
             if default_lov is not None and lov:
             if default_lov is not None and lov:
                 for elt in lov:
                 for elt in lov:
                     ret = self.__gui._run_adapter(
                     ret = self.__gui._run_adapter(
-                        t.cast(t.Callable, adapter), elt, adapter.__name__ if isroutine(adapter) else "adapter"
-                    )  # type: ignore
+                        t.cast(t.Callable, adapter),
+                        elt,
+                        adapter.__name__ if hasattr(adapter, "__name__") else "adapter",
+                    )
                     if ret is not None:
                     if ret is not None:
                         default_lov.append(ret)
                         default_lov.append(ret)
 
 
@@ -459,9 +463,9 @@ class _Builder:
                 ret = self.__gui._run_adapter(
                 ret = self.__gui._run_adapter(
                     t.cast(t.Callable, adapter),
                     t.cast(t.Callable, adapter),
                     val,
                     val,
-                    adapter.__name__ if isroutine(adapter) else "adapter",
+                    adapter.__name__ if hasattr(adapter, "__name__") else "adapter",
                     id_only=True,
                     id_only=True,
-                )  # type: ignore
+                )
                 if ret is not None:
                 if ret is not None:
                     ret_list.append(ret)
                     ret_list.append(ret)
             if multi_selection:
             if multi_selection:
@@ -575,7 +579,7 @@ class _Builder:
         if not isinstance(self.__attributes.get("style"), (type(None), dict, _MapDict)):
         if not isinstance(self.__attributes.get("style"), (type(None), dict, _MapDict)):
             _warn("Table: property 'style' has been renamed to 'row_class_name'.")
             _warn("Table: property 'style' has been renamed to 'row_class_name'.")
         if row_class_name := self.__attributes.get("row_class_name"):
         if row_class_name := self.__attributes.get("row_class_name"):
-            if isroutine(row_class_name):
+            if _is_function(row_class_name):
                 value = self.__hashes.get("row_class_name")
                 value = self.__hashes.get("row_class_name")
             elif isinstance(row_class_name, str):
             elif isinstance(row_class_name, str):
                 value = row_class_name.strip()
                 value = row_class_name.strip()
@@ -586,7 +590,7 @@ class _Builder:
             elif value:
             elif value:
                 self.set_attribute("rowClassName", value)
                 self.set_attribute("rowClassName", value)
         if tooltip := self.__attributes.get("tooltip"):
         if tooltip := self.__attributes.get("tooltip"):
-            if isroutine(tooltip):
+            if _is_function(tooltip):
                 value = self.__hashes.get("tooltip")
                 value = self.__hashes.get("tooltip")
             elif isinstance(tooltip, str):
             elif isinstance(tooltip, str):
                 value = tooltip.strip()
                 value = tooltip.strip()
@@ -806,7 +810,7 @@ class _Builder:
         elif var_type == PropertyType.lov_value:
         elif var_type == PropertyType.lov_value:
             # Done by _get_adapter
             # Done by _get_adapter
             return self
             return self
-        elif isclass(var_type) and issubclass(var_type, _TaipyBase):  # type: ignore
+        elif isclass(var_type) and issubclass(var_type, _TaipyBase):
             return self.__set_default_value(var_name, t.cast(t.Callable, var_type)(value, "").get())
             return self.__set_default_value(var_name, t.cast(t.Callable, var_type)(value, "").get())
         else:
         else:
             return self.__set_json_attribute(default_var_name, value)
             return self.__set_json_attribute(default_var_name, value)

+ 115 - 92
taipy/gui/gui.py

@@ -24,9 +24,9 @@ import typing as t
 import warnings
 import warnings
 from importlib import metadata, util
 from importlib import metadata, util
 from importlib.util import find_spec
 from importlib.util import find_spec
-from inspect import currentframe, getabsfile, ismethod, ismodule, isroutine
+from inspect import currentframe, getabsfile, ismethod, ismodule
 from pathlib import Path
 from pathlib import Path
-from threading import Timer
+from threading import Thread, Timer
 from types import FrameType, FunctionType, LambdaType, ModuleType, SimpleNamespace
 from types import FrameType, FunctionType, LambdaType, ModuleType, SimpleNamespace
 from urllib.parse import unquote, urlencode, urlparse
 from urllib.parse import unquote, urlencode, urlparse
 
 
@@ -51,6 +51,7 @@ if util.find_spec("pyngrok"):
     from pyngrok import ngrok  # type: ignore[reportMissingImports]
     from pyngrok import ngrok  # type: ignore[reportMissingImports]
 
 
 from ._default_config import _default_stylekit, default_config
 from ._default_config import _default_stylekit, default_config
+from ._event_context_manager import _EventManager
 from ._hook import _Hooks
 from ._hook import _Hooks
 from ._page import _Page
 from ._page import _Page
 from ._renderers import _EmptyPage
 from ._renderers import _EmptyPage
@@ -77,6 +78,7 @@ from .utils import (
     _delscopeattr,
     _delscopeattr,
     _DoNotUpdate,
     _DoNotUpdate,
     _filter_locals,
     _filter_locals,
+    _function_name,
     _get_broadcast_var_name,
     _get_broadcast_var_name,
     _get_client_var_name,
     _get_client_var_name,
     _get_css_var_value,
     _get_css_var_value,
@@ -88,7 +90,9 @@ from .utils import (
     _getscopeattr,
     _getscopeattr,
     _getscopeattr_drill,
     _getscopeattr_drill,
     _hasscopeattr,
     _hasscopeattr,
+    _is_function,
     _is_in_notebook,
     _is_in_notebook,
+    _is_unnamed_function,
     _LocalsContext,
     _LocalsContext,
     _MapDict,
     _MapDict,
     _setscopeattr,
     _setscopeattr,
@@ -378,6 +382,8 @@ class Gui:
             ]
             ]
         )
         )
 
 
+        self.__event_manager = _EventManager()
+
         # Init Gui Hooks
         # Init Gui Hooks
         _Hooks()._init(self)
         _Hooks()._init(self)
 
 
@@ -436,7 +442,7 @@ class Gui:
         if Gui.__content_providers.get(content_type):
         if Gui.__content_providers.get(content_type):
             _warn(f"The type {content_type} is already associated with a provider.")
             _warn(f"The type {content_type} is already associated with a provider.")
             return
             return
-        if not callable(content_provider):
+        if not _is_function(content_provider):
             _warn(f"The provider for {content_type} must be a function.")
             _warn(f"The provider for {content_type} must be a function.")
             return
             return
         Gui.__content_providers[content_type] = content_provider
         Gui.__content_providers[content_type] = content_provider
@@ -480,9 +486,9 @@ class Gui:
                         Gui.register_content_provider(MatplotlibFigure, get_matplotlib_content)
                         Gui.register_content_provider(MatplotlibFigure, get_matplotlib_content)
                         provider_fn = get_matplotlib_content
                         provider_fn = get_matplotlib_content
 
 
-            if callable(provider_fn):
+            if _is_function(provider_fn):
                 try:
                 try:
-                    return provider_fn(t.cast(t.Any, content))
+                    return t.cast(t.Callable, provider_fn)(t.cast(t.Any, content))
                 except Exception as e:
                 except Exception as e:
                     _warn(f"Error in content provider for type {str(type(content))}", e)
                     _warn(f"Error in content provider for type {str(type(content))}", e)
         return (
         return (
@@ -821,23 +827,11 @@ class Gui:
             _warn("", e)
             _warn("", e)
             return
             return
         on_change_fn = self._get_user_function(on_change) if on_change else None
         on_change_fn = self._get_user_function(on_change) if on_change else None
-        if not callable(on_change_fn):
+        if not _is_function(on_change_fn):
             on_change_fn = self._get_user_function("on_change")
             on_change_fn = self._get_user_function("on_change")
-        if callable(on_change_fn):
+        if _is_function(on_change_fn):
             try:
             try:
-                arg_count = on_change_fn.__code__.co_argcount
-                if arg_count > 0 and ismethod(on_change_fn):
-                    arg_count -= 1
-                args: t.List[t.Any] = [None for _ in range(arg_count)]
-                if arg_count > 0:
-                    args[0] = self.__get_state()
-                if arg_count > 1:
-                    args[1] = var_name
-                if arg_count > 2:
-                    args[2] = value
-                if arg_count > 3:
-                    args[3] = current_context
-                on_change_fn(*args)
+                self._call_function_with_state(t.cast(t.Callable, on_change_fn), [var_name, value, current_context])
             except Exception as e:  # pragma: no cover
             except Exception as e:  # pragma: no cover
                 if not self._call_on_exception(on_change or "on_change", e):
                 if not self._call_on_exception(on_change or "on_change", e):
                     _warn(f"{on_change or 'on_change'}(): callback function raised an exception", e)
                     _warn(f"{on_change or 'on_change'}(): callback function raised an exception", e)
@@ -879,7 +873,7 @@ class Gui:
             cb_function_name = q_args.get(Gui.__USER_CONTENT_CB)
             cb_function_name = q_args.get(Gui.__USER_CONTENT_CB)
             if cb_function_name:
             if cb_function_name:
                 cb_function = self._get_user_function(cb_function_name)
                 cb_function = self._get_user_function(cb_function_name)
-                if not callable(cb_function):
+                if not _is_function(cb_function):
                     parts = cb_function_name.split(".", 1)
                     parts = cb_function_name.split(".", 1)
                     if len(parts) > 1:
                     if len(parts) > 1:
                         base = _getscopeattr(self, parts[0], None)
                         base = _getscopeattr(self, parts[0], None)
@@ -889,29 +883,29 @@ class Gui:
                             base = self.__evaluator._get_instance_in_context(parts[0])
                             base = self.__evaluator._get_instance_in_context(parts[0])
                             if base and (meth := getattr(base, parts[1], None)):
                             if base and (meth := getattr(base, parts[1], None)):
                                 cb_function = meth
                                 cb_function = meth
-                if not callable(cb_function):
+                if not _is_function(cb_function):
                     _warn(f"{cb_function_name}() callback function has not been defined.")
                     _warn(f"{cb_function_name}() callback function has not been defined.")
                     cb_function = None
                     cb_function = None
         if cb_function is None:
         if cb_function is None:
             cb_function_name = "on_user_content"
             cb_function_name = "on_user_content"
-            if hasattr(self, cb_function_name) and callable(self.on_user_content):
+            if hasattr(self, cb_function_name) and _is_function(self.on_user_content):
                 cb_function = self.on_user_content
                 cb_function = self.on_user_content
             else:
             else:
                 _warn("on_user_content() callback function has not been defined.")
                 _warn("on_user_content() callback function has not been defined.")
-        if callable(cb_function):
+        if _is_function(cb_function):
             try:
             try:
                 args: t.List[t.Any] = []
                 args: t.List[t.Any] = []
                 if path:
                 if path:
                     args.append(path)
                     args.append(path)
                 if len(q_args):
                 if len(q_args):
                     args.append(q_args)
                     args.append(q_args)
-                ret = self._call_function_with_state(cb_function, args)
+                ret = self._call_function_with_state(t.cast(t.Callable, cb_function), args)
                 if ret is None:
                 if ret is None:
                     _warn(f"{cb_function_name}() callback function must return a value.")
                     _warn(f"{cb_function_name}() callback function must return a value.")
                 else:
                 else:
                     return (ret, 200)
                     return (ret, 200)
             except Exception as e:  # pragma: no cover
             except Exception as e:  # pragma: no cover
-                if not self._call_on_exception(str(cb_function_name), e):
+                if not self._call_on_exception(cb_function_name, e):
                     _warn(f"{cb_function_name}() callback function raised an exception", e)
                     _warn(f"{cb_function_name}() callback function raised an exception", e)
         return ("", 404)
         return ("", 404)
 
 
@@ -1038,9 +1032,12 @@ class Gui:
                                 pass
                                 pass
                         data["path"] = file_path
                         data["path"] = file_path
                         file_fn = self._get_user_function(on_upload_action)
                         file_fn = self._get_user_function(on_upload_action)
-                        if not callable(file_fn):
+                        if not _is_function(file_fn):
                             file_fn = _getscopeattr(self, on_upload_action)
                             file_fn = _getscopeattr(self, on_upload_action)
-                        self._call_function_with_state(file_fn, ["file_upload", {"args": [data]}])
+                        if _is_function(file_fn):
+                            self._call_function_with_state(
+                                t.cast(t.Callable, file_fn), ["file_upload", {"args": [data]}]
+                            )
                     else:
                     else:
                         setattr(self._bindings(), var_name, newvalue)
                         setattr(self._bindings(), var_name, newvalue)
         return ("", 200)
         return ("", 200)
@@ -1446,13 +1443,13 @@ class Gui:
         func = (
         func = (
             getattr(self, func_name.split(".", 2)[1], func_name) if func_name.startswith(f"{Gui.__SELF_VAR}.") else None
             getattr(self, func_name.split(".", 2)[1], func_name) if func_name.startswith(f"{Gui.__SELF_VAR}.") else None
         )
         )
-        if not callable(func):
+        if not _is_function(func):
             func = _getscopeattr(self, func_name, None)
             func = _getscopeattr(self, func_name, None)
-        if not callable(func):
+        if not _is_function(func):
             func = self._get_locals_bind().get(func_name)
             func = self._get_locals_bind().get(func_name)
-        if not callable(func):
+        if not _is_function(func):
             func = self.__locals_context.get_default().get(func_name)
             func = self.__locals_context.get_default().get(func_name)
-        return func if callable(func) else func_name
+        return t.cast(t.Callable, func) if _is_function(func) else func_name
 
 
     def _get_user_instance(self, class_name: str, class_type: type) -> t.Union[object, str]:
     def _get_user_instance(self, class_name: str, class_type: type) -> t.Union[object, str]:
         cls = _getscopeattr(self, class_name, None)
         cls = _getscopeattr(self, class_name, None)
@@ -1508,26 +1505,17 @@ class Gui:
         id = t.cast(str, kwargs.get("id"))
         id = t.cast(str, kwargs.get("id"))
         payload = kwargs.get("payload")
         payload = kwargs.get("payload")
 
 
-        if callable(action_function):
+        if _is_function(action_function):
             try:
             try:
-                argcount = action_function.__code__.co_argcount
-                if argcount > 0 and ismethod(action_function):
-                    argcount -= 1
-                args = t.cast(list, [None for _ in range(argcount)])
-                if argcount > 0:
-                    args[0] = self.__get_state()
-                if argcount > 1:
-                    try:
-                        args[1] = self._get_real_var_name(id)[0]
-                    except Exception:
-                        args[1] = id
-                if argcount > 2:
-                    args[2] = payload
-                action_function(*args)
+                try:
+                    args = [self._get_real_var_name(id)[0], payload]
+                except Exception:
+                    args = [id, payload]
+                self._call_function_with_state(t.cast(t.Callable, action_function), [args])
                 return True
                 return True
             except Exception as e:  # pragma: no cover
             except Exception as e:  # pragma: no cover
-                if not self._call_on_exception(action_function.__name__, e):
-                    _warn(f"on_action(): Exception raised in '{action_function.__name__}()'", e)
+                if not self._call_on_exception(action_function, e):
+                    _warn(f"on_action(): Exception raised in '{_function_name(action_function)}()'", e)
         return False
         return False
 
 
     def _call_function_with_state(self, user_function: t.Callable, args: t.Optional[t.List[t.Any]] = None) -> t.Any:
     def _call_function_with_state(self, user_function: t.Callable, args: t.Optional[t.List[t.Any]] = None) -> t.Any:
@@ -1540,7 +1528,8 @@ class Gui:
             cp_args += (argcount - len(cp_args)) * [None]
             cp_args += (argcount - len(cp_args)) * [None]
         else:
         else:
             cp_args = cp_args[:argcount]
             cp_args = cp_args[:argcount]
-        return user_function(*cp_args)
+        with self.__event_manager:
+            return user_function(*cp_args)
 
 
     def _set_module_context(self, module_context: t.Optional[str]) -> t.ContextManager[None]:
     def _set_module_context(self, module_context: t.Optional[str]) -> t.ContextManager[None]:
         return self._set_locals_context(module_context) if module_context is not None else contextlib.nullcontext()
         return self._set_locals_context(module_context) if module_context is not None else contextlib.nullcontext()
@@ -1548,7 +1537,7 @@ class Gui:
     def invoke_callback(
     def invoke_callback(
         self,
         self,
         state_id: str,
         state_id: str,
-        callback: t.Callable,
+        callback: t.Union[str, t.Callable],
         args: t.Optional[t.Sequence[t.Any]] = None,
         args: t.Optional[t.Sequence[t.Any]] = None,
         module_context: t.Optional[str] = None,
         module_context: t.Optional[str] = None,
     ) -> t.Any:
     ) -> t.Any:
@@ -1559,7 +1548,7 @@ class Gui:
 
 
         Arguments:
         Arguments:
             state_id: The identifier of the state to use, as returned by `get_state_id()^`.
             state_id: The identifier of the state to use, as returned by `get_state_id()^`.
-            callback (Callable[[State^, ...], None]): The user-defined function that is invoked.<br/>
+            callback (Union[str, Callable[[State^, ...], None]]): The user-defined function that is invoked.<br/>
                 The first parameter of this function **must** be a `State^`.
                 The first parameter of this function **must** be a `State^`.
             args (Optional[Sequence]): The remaining arguments, as a List or a Tuple.
             args (Optional[Sequence]): The remaining arguments, as a List or a Tuple.
             module_context (Optional[str]): The name of the module that will be used.
             module_context (Optional[str]): The name of the module that will be used.
@@ -1573,21 +1562,20 @@ class Gui:
             with self.get_flask_app().app_context():
             with self.get_flask_app().app_context():
                 setattr(g, Gui.__ARG_CLIENT_ID, state_id)
                 setattr(g, Gui.__ARG_CLIENT_ID, state_id)
                 with self._set_module_context(module_context):
                 with self._set_module_context(module_context):
-                    if not callable(callback):
-                        callback = self._get_user_function(callback)
-                    if not callable(callback):
+                    if not _is_function(callback):
+                        callback = self._get_user_function(t.cast(str, callback))
+                    if not _is_function(callback):
                         _warn(f"invoke_callback(): {callback} is not callable.")
                         _warn(f"invoke_callback(): {callback} is not callable.")
                         return None
                         return None
-                    return self._call_function_with_state(callback, list(args) if args else None)
+                    return self._call_function_with_state(t.cast(t.Callable, callback), list(args) if args else None)
         except Exception as e:  # pragma: no cover
         except Exception as e:  # pragma: no cover
-            if not self._call_on_exception(callback.__name__ if callable(callback) else callback, e):
+            if not self._call_on_exception(callback, e):
                 _warn(
                 _warn(
-                    "Gui.invoke_callback(): Exception raised in "
-                    + f"'{callback.__name__ if callable(callback) else callback}()'",
+                    f"Gui.invoke_callback(): Exception raised in {_function_name(callback)}",
                     e,
                     e,
                 )
                 )
         finally:
         finally:
-            if this_sid and request:
+            if this_sid:
                 request.sid = this_sid  # type: ignore[attr-defined]
                 request.sid = this_sid  # type: ignore[attr-defined]
         return None
         return None
 
 
@@ -2174,13 +2162,13 @@ class Gui:
 
 
     def __bind_local_func(self, name: str):
     def __bind_local_func(self, name: str):
         func = getattr(self, name, None)
         func = getattr(self, name, None)
-        if func is not None and not callable(func):  # pragma: no cover
+        if func is not None and not _is_function(func):  # pragma: no cover
             _warn(f"{self.__class__.__name__}.{name}: {func} should be a function; looking for {name} in the script.")
             _warn(f"{self.__class__.__name__}.{name}: {func} should be a function; looking for {name} in the script.")
             func = None
             func = None
         if func is None:
         if func is None:
             func = self._get_locals_bind().get(name)
             func = self._get_locals_bind().get(name)
         if func is not None:
         if func is not None:
-            if callable(func):
+            if _is_function(func):
                 setattr(self, name, func)
                 setattr(self, name, func)
             else:  # pragma: no cover
             else:  # pragma: no cover
                 _warn(f"{name}: {func} should be a function.")
                 _warn(f"{name}: {func} should be a function.")
@@ -2225,11 +2213,11 @@ class Gui:
     def _download(
     def _download(
         self, content: t.Any, name: t.Optional[str] = "", on_action: t.Optional[t.Union[str, t.Callable]] = ""
         self, content: t.Any, name: t.Optional[str] = "", on_action: t.Optional[t.Union[str, t.Callable]] = ""
     ):
     ):
-        if isroutine(on_action) and on_action.__name__:
+        if _is_function(on_action):
             on_action_name = (
             on_action_name = (
                 _get_lambda_id(t.cast(LambdaType, on_action))
                 _get_lambda_id(t.cast(LambdaType, on_action))
-                if on_action.__name__ == "<lambda>"
-                else _get_expr_var_name(on_action.__name__)
+                if _is_unnamed_function(on_action)
+                else _get_expr_var_name(t.cast(t.Callable, on_action).__name__)
             )
             )
             if on_action_name:
             if on_action_name:
                 self._bind_var_val(on_action_name, on_action)
                 self._bind_var_val(on_action_name, on_action)
@@ -2258,8 +2246,13 @@ class Gui:
         callback: t.Optional[t.Union[str, t.Callable]] = None,
         callback: t.Optional[t.Union[str, t.Callable]] = None,
         message: t.Optional[str] = "Work in Progress...",
         message: t.Optional[str] = "Work in Progress...",
     ):  # pragma: no cover
     ):  # pragma: no cover
-        action_name = callback.__name__ if callable(callback) else callback
-        # TODO: what if lambda? (it does work)
+        action_name = (
+            callback
+            if isinstance(callback, str)
+            else _get_lambda_id(t.cast(LambdaType, callback))
+            if _is_unnamed_function(callback)
+            else callback.__name__ if callback is not None else None
+        )
         func = self.__get_on_cancel_block_ui(action_name)
         func = self.__get_on_cancel_block_ui(action_name)
         def_action_name = func.__name__
         def_action_name = func.__name__
         _setscopeattr(self, def_action_name, func)
         _setscopeattr(self, def_action_name, func)
@@ -2306,27 +2299,28 @@ class Gui:
             _setscopeattr(self, Gui.__ON_INIT_NAME, True)
             _setscopeattr(self, Gui.__ON_INIT_NAME, True)
             self.__pre_render_pages()
             self.__pre_render_pages()
             self.__init_libs()
             self.__init_libs()
-            if hasattr(self, "on_init") and callable(self.on_init):
+            if hasattr(self, "on_init") and _is_function(self.on_init):
                 try:
                 try:
-                    self._call_function_with_state(self.on_init)
+                    self._call_function_with_state(t.cast(t.Callable, self.on_init))
                 except Exception as e:  # pragma: no cover
                 except Exception as e:  # pragma: no cover
                     if not self._call_on_exception("on_init", e):
                     if not self._call_on_exception("on_init", e):
                         _warn("Exception raised in on_init()", e)
                         _warn("Exception raised in on_init()", e)
         return self._render_route()
         return self._render_route()
 
 
-    def _call_on_exception(self, function_name: str, exception: Exception) -> bool:
-        if hasattr(self, "on_exception") and callable(self.on_exception):
+    def _call_on_exception(self, function: t.Any, exception: Exception) -> bool:
+        if hasattr(self, "on_exception") and _is_function(self.on_exception):
+            function_name = _function_name(function) if callable(function) else str(function)
             try:
             try:
-                self.on_exception(self.__get_state(), function_name, exception)
+                self._call_function_with_state(t.cast(t.Callable, self.on_exception), [function_name, exception])
             except Exception as e:  # pragma: no cover
             except Exception as e:  # pragma: no cover
                 _warn("Exception raised in on_exception()", e)
                 _warn("Exception raised in on_exception()", e)
             return True
             return True
         return False
         return False
 
 
     def __call_on_status(self) -> t.Optional[str]:
     def __call_on_status(self) -> t.Optional[str]:
-        if hasattr(self, "on_status") and callable(self.on_status):
+        if hasattr(self, "on_status") and _is_function(self.on_status):
             try:
             try:
-                return self.on_status(self.__get_state())
+                return self._call_function_with_state(t.cast(t.Callable, self.on_status))
             except Exception as e:  # pragma: no cover
             except Exception as e:  # pragma: no cover
                 if not self._call_on_exception("on_status", e):
                 if not self._call_on_exception("on_status", e):
                     _warn("Exception raised in on_status", e)
                     _warn("Exception raised in on_status", e)
@@ -2349,15 +2343,12 @@ class Gui:
 
 
     def _get_navigated_page(self, page_name: str) -> t.Any:
     def _get_navigated_page(self, page_name: str) -> t.Any:
         nav_page = page_name
         nav_page = page_name
-        if hasattr(self, "on_navigate") and callable(self.on_navigate):
+        if hasattr(self, "on_navigate") and _is_function(self.on_navigate):
             try:
             try:
-                if self.on_navigate.__code__.co_argcount == 2:
-                    nav_page = self.on_navigate(self.__get_state(), page_name)
-                else:
-                    params = request.args.to_dict() if hasattr(request, "args") else {}
-                    params.pop("client_id", None)
-                    params.pop("v", None)
-                    nav_page = self.on_navigate(self.__get_state(), page_name, params)
+                params = request.args.to_dict() if hasattr(request, "args") else {}
+                params.pop("client_id", None)
+                params.pop("v", None)
+                nav_page = self._call_function_with_state(t.cast(t.Callable, self.on_navigate), [page_name, params])
                 if nav_page != page_name:
                 if nav_page != page_name:
                     if isinstance(nav_page, str):
                     if isinstance(nav_page, str):
                         if self._navigate(nav_page):
                         if self._navigate(nav_page):
@@ -2374,18 +2365,10 @@ class Gui:
         if page_name == Gui.__root_page_name:
         if page_name == Gui.__root_page_name:
             page_name = "/"
             page_name = "/"
         on_page_load_fn = self._get_user_function("on_page_load")
         on_page_load_fn = self._get_user_function("on_page_load")
-        if not callable(on_page_load_fn):
+        if not _is_function(on_page_load_fn):
             return
             return
         try:
         try:
-            arg_count = on_page_load_fn.__code__.co_argcount
-            if arg_count > 0 and ismethod(on_page_load_fn):
-                arg_count -= 1
-            args: t.List[t.Any] = [None for _ in range(arg_count)]
-            if arg_count > 0:
-                args[0] = self.__get_state()
-            if arg_count > 1:
-                args[1] = page_name
-            on_page_load_fn(*args)
+            self._call_function_with_state(t.cast(t.Callable, on_page_load_fn), [page_name])
         except Exception as e:
         except Exception as e:
             if not self._call_on_exception("on_page_load", e):
             if not self._call_on_exception("on_page_load", e):
                 _warn("Exception raised in on_page_load()", e)
                 _warn("Exception raised in on_page_load()", e)
@@ -2907,3 +2890,43 @@ class Gui:
             self._broadcast(
             self._broadcast(
                 "taipy_favicon", url, self._get_client_id() if state else None, message_type=_WsType.FAVICON
                 "taipy_favicon", url, self._get_client_id() if state else None, message_type=_WsType.FAVICON
             )
             )
+
+    @staticmethod
+    def _add_event_listener(
+        event_name: str,
+        listener: t.Union[
+            t.Callable[[str, t.Dict[str, t.Any]], None], t.Callable[[State, str, t.Dict[str, t.Any]], None]
+        ],
+        with_state: t.Optional[bool] = False,
+    ):
+        _Hooks()._add_event_listener(event_name, listener, with_state)
+
+    def _fire_event(
+        self, event_name: str, client_id: t.Optional[str] = None, payload: t.Optional[t.Dict[str, t.Any]] = None
+    ):
+        # the event manager will take care of starting the thread
+        # once the current callback (or the next one) is finished
+        self.__event_manager._add_thread(
+            Thread(
+                target=self.__do_fire_event,
+                args=(event_name, client_id, payload),
+            )
+        )
+
+    def __do_fire_event(
+        self, event_name: str, client_id: t.Optional[str] = None, payload: t.Optional[t.Dict[str, t.Any]] = None
+    ):
+        this_sid = None
+        if request:
+            # avoid messing with the client_id => Set(ws id)
+            this_sid = getattr(request, "sid", None)
+            request.sid = None  # type: ignore[attr-defined]
+
+        try:
+            with self.get_flask_app().app_context(), self.__event_manager:
+                if client_id:
+                    setattr(g, Gui.__ARG_CLIENT_ID, client_id)
+                _Hooks()._fire_event(event_name, client_id, payload)
+        finally:
+            if this_sid:
+                request.sid = this_sid  # type: ignore[attr-defined]

+ 1 - 0
taipy/gui/utils/__init__.py

@@ -23,6 +23,7 @@ from ._map_dict import _MapDict
 from ._runtime_manager import _RuntimeManager
 from ._runtime_manager import _RuntimeManager
 from ._variable_directory import _variable_decode, _variable_encode, _VariableDirectory
 from ._variable_directory import _variable_decode, _variable_encode, _VariableDirectory
 from .boolean import _is_boolean, _is_true
 from .boolean import _is_boolean, _is_true
+from .callable import _function_name, _is_function, _is_unnamed_function
 from .clientvarname import _get_broadcast_var_name, _get_client_var_name, _to_camel_case
 from .clientvarname import _get_broadcast_var_name, _get_client_var_name, _to_camel_case
 from .datatype import _get_data_type
 from .datatype import _get_data_type
 from .date import _date_to_string, _string_to_date
 from .date import _date_to_string, _string_to_date

+ 1 - 1
taipy/gui/utils/_evaluator.py

@@ -263,7 +263,7 @@ class _Evaluator:
             _warn(f"Cannot evaluate expression '{not_encoded_expr if is_edge_case else expr_string}'", e)
             _warn(f"Cannot evaluate expression '{not_encoded_expr if is_edge_case else expr_string}'", e)
             expr_evaluated = None
             expr_evaluated = None
         if lambda_expr and callable(expr_evaluated):
         if lambda_expr and callable(expr_evaluated):
-            expr_hash = _get_lambda_id(expr_evaluated, module=module_name)
+            expr_hash = _get_lambda_id(expr_evaluated, module=module_name)  # type: ignore[reportArgumentType]
         # save the expression if it needs to be re-evaluated
         # save the expression if it needs to be re-evaluated
         return self.__save_expression(gui, expr, expr_hash, expr_evaluated, var_map, lambda_expr)
         return self.__save_expression(gui, expr, expr_hash, expr_evaluated, var_map, lambda_expr)
 
 

+ 31 - 0
taipy/gui/utils/callable.py

@@ -0,0 +1,31 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.
+
+import typing as t
+from inspect import isclass
+from types import LambdaType
+
+
+def _is_function(s: t.Any) -> bool:
+    return callable(s) and not isclass(s)
+
+
+def _function_name(s: t.Any) -> str:
+    if hasattr(s, "__name__"):
+        return s.__name__
+    elif callable(s):
+        return f"<instance of {type(s).__name__}>"
+    else:
+        return str(s)
+
+
+def _is_unnamed_function(s: t.Any):
+    return isinstance(s, LambdaType) or (callable(s) and not hasattr(s, "__name__"))

+ 10 - 9
taipy/gui_core/_GuiCoreLib.py

@@ -13,7 +13,7 @@ import typing as t
 from datetime import datetime
 from datetime import datetime
 
 
 from taipy.core import Cycle, DataNode, Job, Scenario, Sequence, Task
 from taipy.core import Cycle, DataNode, Job, Scenario, Sequence, Task
-from taipy.gui import Gui
+from taipy.gui import Gui, State
 from taipy.gui.extension import Element, ElementLibrary, ElementProperty, PropertyType
 from taipy.gui.extension import Element, ElementLibrary, ElementProperty, PropertyType
 
 
 from ..version import _get_version
 from ..version import _get_version
@@ -103,9 +103,7 @@ class _GuiCore(ElementLibrary):
                     f"{{{__CTX_VAR_NAME}.get_scenario_by_id({__SCENARIO_SELECTOR_ID_VAR}<tp:uniq:sc>)}}",
                     f"{{{__CTX_VAR_NAME}.get_scenario_by_id({__SCENARIO_SELECTOR_ID_VAR}<tp:uniq:sc>)}}",
                 ),
                 ),
                 "on_scenario_select": ElementProperty(PropertyType.function, f"{{{__CTX_VAR_NAME}.select_scenario}}"),
                 "on_scenario_select": ElementProperty(PropertyType.function, f"{{{__CTX_VAR_NAME}.select_scenario}}"),
-                "creation_not_allowed": ElementProperty(
-                    PropertyType.string, f"{{{__CTX_VAR_NAME}.get_creation_reason()}}"
-                ),
+                "creation_not_allowed": ElementProperty(PropertyType.broadcast, _GuiCoreContext._AUTH_CHANGED_NAME),
                 "update_sc_vars": ElementProperty(
                 "update_sc_vars": ElementProperty(
                     PropertyType.string,
                     PropertyType.string,
                     f"filter={__SCENARIO_SELECTOR_FILTER_VAR}<tp:uniq:sc>;"
                     f"filter={__SCENARIO_SELECTOR_FILTER_VAR}<tp:uniq:sc>;"
@@ -321,11 +319,14 @@ class _GuiCore(ElementLibrary):
         return ["lib/taipy-gui-core.js"]
         return ["lib/taipy-gui-core.js"]
 
 
     def on_init(self, gui: Gui) -> t.Optional[t.Tuple[str, t.Any]]:
     def on_init(self, gui: Gui) -> t.Optional[t.Tuple[str, t.Any]]:
-        ctx = _GuiCoreContext(gui)
-        gui._add_adapter_for_type(_GuiCore.__SCENARIO_ADAPTER, ctx.scenario_adapter)
-        gui._add_adapter_for_type(_GuiCore.__DATANODE_ADAPTER, ctx.data_node_adapter)
-        gui._add_adapter_for_type(_GuiCore.__JOB_ADAPTER, ctx.job_adapter)
-        return _GuiCore.__CTX_VAR_NAME, ctx
+        self.ctx = _GuiCoreContext(gui)
+        gui._add_adapter_for_type(_GuiCore.__SCENARIO_ADAPTER, self.ctx.scenario_adapter)
+        gui._add_adapter_for_type(_GuiCore.__DATANODE_ADAPTER, self.ctx.data_node_adapter)
+        gui._add_adapter_for_type(_GuiCore.__JOB_ADAPTER, self.ctx.job_adapter)
+        return _GuiCore.__CTX_VAR_NAME, self.ctx
+
+    def on_user_init(self, state: State):
+        self.ctx.on_user_init(state)
 
 
     def get_version(self) -> str:
     def get_version(self) -> str:
         if not hasattr(self, "version"):
         if not hasattr(self, "version"):

+ 17 - 7
taipy/gui_core/_context.py

@@ -34,6 +34,7 @@ from taipy.core import (
     SequenceId,
     SequenceId,
     Submission,
     Submission,
     SubmissionId,
     SubmissionId,
+    can_create,
     cancel_job,
     cancel_job,
     create_scenario,
     create_scenario,
     delete_job,
     delete_job,
@@ -56,7 +57,7 @@ from taipy.core.notification.event import Event, EventOperation
 from taipy.core.notification.notifier import Notifier
 from taipy.core.notification.notifier import Notifier
 from taipy.core.reason import ReasonCollection
 from taipy.core.reason import ReasonCollection
 from taipy.core.submission.submission_status import SubmissionStatus
 from taipy.core.submission.submission_status import SubmissionStatus
-from taipy.gui import Gui, State
+from taipy.gui import Gui, State, get_state_id
 from taipy.gui._warnings import _warn
 from taipy.gui._warnings import _warn
 from taipy.gui.gui import _DoNotUpdate
 from taipy.gui.gui import _DoNotUpdate
 from taipy.gui.utils._map_dict import _MapDict
 from taipy.gui.utils._map_dict import _MapDict
@@ -82,6 +83,7 @@ class _GuiCoreContext(CoreEventConsumerBase):
     __ENTITY_PROPS = (__PROP_CONFIG_ID, __PROP_DATE, __PROP_ENTITY_NAME)
     __ENTITY_PROPS = (__PROP_CONFIG_ID, __PROP_DATE, __PROP_ENTITY_NAME)
     __ACTION = "action"
     __ACTION = "action"
     _CORE_CHANGED_NAME = "core_changed"
     _CORE_CHANGED_NAME = "core_changed"
+    _AUTH_CHANGED_NAME = "auth_changed"
 
 
     def __init__(self, gui: Gui) -> None:
     def __init__(self, gui: Gui) -> None:
         self.gui = gui
         self.gui = gui
@@ -97,9 +99,14 @@ class _GuiCoreContext(CoreEventConsumerBase):
         self.submissions_lock = Lock()
         self.submissions_lock = Lock()
         # lazy_start
         # lazy_start
         self.__started = False
         self.__started = False
+        # Gui event listener
+        gui._add_event_listener("authorization", self._auth_listener, with_state=True)
         # super
         # super
         super().__init__(reg_id, reg_queue)
         super().__init__(reg_id, reg_queue)
 
 
+    def on_user_init(self, state: State):
+        self.gui._fire_event("authorization", get_state_id(state), {})
+
     def __lazy_start(self):
     def __lazy_start(self):
         if self.__started:
         if self.__started:
             return
             return
@@ -1199,12 +1206,6 @@ class _GuiCoreContext(CoreEventConsumerBase):
         elif args[1]:
         elif args[1]:
             _warn(f"dag.on_action(): Invalid function '{args[1]}()'.")
             _warn(f"dag.on_action(): Invalid function '{args[1]}()'.")
 
 
-    def get_creation_reason(self):
-        self.__lazy_start()
-        # make this dynamic
-        # return "" if (reason := can_create()) else f"Cannot create scenario: {_get_reason(reason)}"
-        return ""
-
     def on_file_action(self, state: State, id: str, payload: t.Dict[str, t.Any]):
     def on_file_action(self, state: State, id: str, payload: t.Dict[str, t.Any]):
         args = t.cast(list, payload.get("args"))
         args = t.cast(list, payload.get("args"))
         act_payload = t.cast(t.Dict[str, str], args[0])
         act_payload = t.cast(t.Dict[str, str], args[0])
@@ -1240,6 +1241,15 @@ class _GuiCoreContext(CoreEventConsumerBase):
         else:
         else:
             state.assign(error_id, reason.reasons)
             state.assign(error_id, reason.reasons)
 
 
+    def _auth_listener(self, state: State, client_id: t.Optional[str], payload: t.Dict[str, t.Any]):
+        self.gui._broadcast(
+            _GuiCoreContext._AUTH_CHANGED_NAME,
+            payload.get("override", "")
+            if (reason := can_create())
+            else f"Cannot create scenario: {_get_reason(reason)}",
+            client_id,
+        )
+
 
 
 def _get_reason(reason: t.Union[bool, ReasonCollection]):
 def _get_reason(reason: t.Union[bool, ReasonCollection]):
     return reason.reasons if isinstance(reason, ReasonCollection) else " "
     return reason.reasons if isinstance(reason, ReasonCollection) else " "

+ 10 - 0
tests/gui/hooks/__init__.py

@@ -0,0 +1,10 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.

+ 66 - 0
tests/gui/hooks/test_listener.py

@@ -0,0 +1,66 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.
+import time
+import typing as t
+from unittest.mock import MagicMock
+
+from taipy.gui import Gui
+from taipy.gui._hook import _Hook, _Hooks
+
+
+def test_empty_listener(gui: Gui):
+    listener = MagicMock()
+    event = "an_event"
+    payload = {"a": 1}
+
+    gui._add_event_listener(event, listener)
+
+    gui._fire_event(event, None, payload)
+
+    listener.assert_not_called()
+
+def test_listener(gui: Gui):
+    class ListenerHook(_Hook):
+        method_names = ["_add_event_listener", "_fire_event"]
+
+        def __init__(self):
+            super().__init__()
+            self.listeners = {}
+
+        def _add_event_listener(
+            self,
+            event_name: str,
+            listener: t.Callable[[str, t.Dict[str, t.Any]], None],
+            with_state: t.Optional[bool] = False,
+        ):
+            self.listeners[event_name] = listener
+
+        def _fire_event(
+            self, event_name: str, client_id: t.Optional[str] = None, payload: t.Optional[t.Dict[str, t.Any]] = None
+        ):
+            if func := self.listeners.get(event_name):
+                func(event_name, client_id, payload)
+
+    gui.run(run_server=False, single_client=True, stylekit=False)
+
+    _Hooks()._register_hook(ListenerHook())
+
+    listener = MagicMock()
+    event = "an_event"
+    payload = {"a": 1}
+
+    gui._add_event_listener(event, listener)
+
+    with gui._Gui__event_manager: # type: ignore[attr-defined]
+        gui._fire_event(event, None, payload)
+
+    time.sleep(0.3)
+    listener.assert_called_once_with(event, None, payload)