from __future__ import annotations
import typing as tp
from asyncio import get_event_loop
from collections.abc import Awaitable, Callable, Coroutine
from typing import Any, Generic, ParamSpec, TypeVar, cast
from edifice.engine import Reference, _T_use_state, get_render_context_maybe
from edifice.qt import QT_VERSION
if QT_VERSION == "PyQt6" and not tp.TYPE_CHECKING:
from PyQt6 import QtGui
else:
from PySide6 import QtGui # noqa: TCH002
[docs]
def use_state(
initial_state: _T_use_state | Callable[[], _T_use_state],
) -> tuple[
_T_use_state, # current value
Callable[[_T_use_state | Callable[[_T_use_state], _T_use_state]], None], # updater
]:
"""
Persistent mutable state Hook inside a :func:`@component<edifice.component>` function.
Behaves like `React useState <https://react.dev/reference/react/useState>`_.
:func:`use_state` is called with an **initial state**.
It returns a **state value** and a
**setter function**.
The **state value** will be the value of the state at the beginning of
the render for this component.
The **setter function** will, when called, set the **state value** before the
beginning of the next render.
If the new **state value** is not :code:`__eq__` to the
old **state value**, then the component will be re-rendered.
.. code-block:: python
@component
def Stateful(self):
x, x_setter = use_state(0)
Button(
title=str(x)
on_click = lambda _event: x_setter(x + 1)
)
The **setter function** should be called inside of an event handler
or a :func:`use_effect` function.
Never call the **setter function**
directly during a :func:`@component<edifice.component>` render function.
.. warning::
The **state value** must not be a
`Callable <https://docs.python.org/3/library/collections.abc.html#collections.abc.Callable>`_,
so that Edifice does not mistake it for an **initializer function**
or an **updater function**.
If you want to store a :code:`Callable` value, like a function, then wrap
it in a :code:`tuple` or some other non-:code:`Callable` data structure.
Initialization
--------------
An **initializer function** is a function of no arguments.
If an **initializer function** is passed to :func:`use_state` instead
of an initial value, then the
**initializer function** will be called once before
this :func:`components`’s first render to get the **initial state**.
.. code-block:: python
:caption: Initializer function
def initializer() -> tuple[int]:
return tuple(range(1000000))
intlist, intlist_set = use_state(initializer)
This is useful for one-time construction of **initial state** if the
**initial state** is expensive to compute.
If an **initializer function** raises an exception then Edifice will crash.
Do not perform observable side effects inside the **initializer function**.
* Do not write to files or network.
* Do not call a **setter function** of another :func:`use_state` Hook.
For these kinds of initialization side effects, use :func:`use_effect` instead,
or :func:`use_async` for very long-running initial side effects.
Using the **initializer function** for initial side effects is good for
some cases where the side effect has a predictable result and cannot fail,
like for example setting global styles in the root Element, or reading
small configuration files.
Update
------
An **updater function** is a function from the previous state to the new state.
If an **updater function** is passed to the **setter function**, then before the
beginning of the next render the **state value** will be modified by calling all of the
**updater functions** in the order in which they were set.
.. code-block:: python
:caption: Updater function
@component
def Stateful(self):
x, x_setter = use_state(0)
def updater(x_previous:int) -> int:
return x_previous + 1
Button(
title=str(x)
on_click = lambda _event: x_setter(updater)
)
If any of the **updater functions** raises an exception then Edifice will
crash.
State must not be mutated
-------------------------
Do not mutate the state variable. The old state variable must be left
unmodified so that it can be compared to the new state variable during
the next render.
If Python does not have an
`immutable <https://docs.python.org/3/glossary.html#term-immutable>`_
version of your state data structure,
like for example the :code:`dict`, then you just have to take care to never
mutate it.
Instead of mutating a state :code:`list`, create a
shallow `copy <https://docs.python.org/3/library/copy.html#copy.copy>`_
of the :code:`list`, modify the copy, then call the **setter function**
with the modified copy.
.. code-block:: python
:caption: Updater function with shallow copy of a list
from copy import copy
from typing import cast
def Stateful(self):
x, x_setter = use_state(cast(list[str], []))
def updater(x_previous:list[str]) -> list[str]:
x_new = copy(x_previous)
x_new.append("Label Text " + str(len(x_previous)))
return x_new
with View():
Button(
title="Add One",
on_click = lambda _event: x_setter(updater)
)
for t in x:
Label(text=t)
Techniques for `immutable <https://docs.python.org/3/glossary.html#term-immutable>`_ datastructures in Python
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
- `Shallow copy <https://docs.python.org/3/library/copy.html#copy.copy>`_.
We never need a deep copy because all the data structure items are also immutable.
- `Frozen dataclasses <https://docs.python.org/3/library/dataclasses.html#frozen-instances>`_.
Use the
`replace() <https://docs.python.org/3/library/dataclasses.html#dataclasses.replace>`_
function to update the dataclass.
- Tuples (:code:`my_list:tuple[str, ...]`) instead of lists (:code:`my_list:list[str]`).
.. code-block:: python
:caption: Updater function with shallow copy of a tuple
from typing import cast
def Stateful(self):
x, x_setter = use_state(cast(tuple[str, ...], ()))
def updater(x_previous:tuple[str, ...]) -> tuple[str, ...]:
return x_previous + ("Label Text " + str(len(x_previous)),)
with View():
Button(
title="Add One",
on_click = lambda _event: x_setter(updater)
)
for t in x:
Label(text=t)
Args:
initial_state: The initial **state value** or **initializer function**.
Returns:
A tuple pair containing
1. The current **state value**.
2. A **setter function** for setting or updating the state value.
"""
context = get_render_context_maybe()
if context is None or context.current_element is None:
raise ValueError("use_state used outside component")
return context.engine.use_state(context.current_element, initial_state)
[docs]
def use_effect(
setup: Callable[[], Callable[[], None] | None],
dependencies: Any = None,
) -> None:
"""
Side-effect Hook inside a :func:`@component<edifice.component>` function.
Behaves like `React useEffect <https://react.dev/reference/react/useEffect>`_.
The **setup function** will be called after render and after the underlying
Qt Widgets are updated.
The **setup function** may return a **cleanup function**.
If the :code:`dependencies` in the next render are not :code:`__eq__` to
the dependencies from the last render, then the **cleanup function** is
called and then the new **setup function** is called.
The **cleanup function** will be called by Edifice exactly once for
each call to the **setup function**.
The **cleanup function**
is called after render and before the component is deleted.
If the :code:`dependencies` are :code:`None`, then the new effect
**setup function** will always be called after every render.
If you want to call the **setup function** only once, then pass an empty
tuple :code:`()` as the :code:`dependencies`.
If the **setup function** raises an Exception then the
**cleanup function** will not be called.
Exceptions raised from the **setup function** and **cleanup function**
will be suppressed.
The **setup function** can return :code:`None` if there is no
**cleanup function**.
The **setup function** and **cleanup function** can call the setter of
a :func:`use_state` Hook to update the application state.
.. code-block:: python
:caption: use_effect to attach and remove an event handler
@component
def Effective(self, handler):
def setup_handler():
token = attach_event_handler(handler)
def cleanup_handler():
remove_event_handler(token)
return cleanup_handler
use_effect(setup_handler, handler)
Args:
setup:
An effect **setup function** which returns a **cleanup function**
or :code:`None`.
dependencies:
The effect **setup function** will be called when the
dependencies are not :code:`__eq__` to the old dependencies.
If the dependencies are :code:`None`, then the effect
**setup function** will always be called.
Returns:
None
"""
context = get_render_context_maybe()
if context is None or context.current_element is None:
raise ValueError("use_effect used outside component")
return context.engine.use_effect(context.current_element, setup, dependencies)
[docs]
def use_async(
fn_coroutine: Callable[[], Coroutine[None, None, None]],
dependencies: Any,
) -> Callable[[], None]:
"""
Asynchronous side-effect Hook inside a :func:`@component<edifice.component>` function.
Will create a new
`Task <https://docs.python.org/3/library/asyncio-task.html#asyncio.Task>`_
with the :code:`fn_coroutine` coroutine.
The :code:`fn_coroutine` will be called every time the :code:`dependencies` change.
Only one :code:`fn_coroutine` will be allowed to run at a time.
.. code-block:: python
:caption: use_async to fetch from the network
@component
def WordDefinition(self, word:str):
definition, definition_set = use_state("")
async def fetcher():
try:
definition_set("Fetch definition pending")
x = await fetch_definition_from_the_internet(word)
definition_set(x)
except asyncio.CancelledError:
definition_set("Fetch definition cancelled")
raise
except BaseException:
defintion_set("Fetch definition failed")
cancel_fetcher = use_async(fetcher, word)
with VBoxView():
Label(text=word)
Label(text=definition)
Button(text="Cancel fetch", on_click=lambda _:cancel_fetcher())
Cancellation
============
The async :code:`fn_coroutine` Task can be cancelled by Edifice. Edifice will call
`cancel() <https://docs.python.org/3/library/asyncio-task.html#asyncio.Task.cancel>`_
on the Task.
See also
`Task Cancellation <https://docs.python.org/3/library/asyncio-task.html#task-cancellation>`_.
1. If the :code:`dependencies` change before the :code:`fn_coroutine` Task completes, then
the :code:`fn_coroutine` Task will be cancelled and then the new
:code:`fn_coroutine` Task will
be started after the old :code:`fn_coroutine` Task completes.
2. The :code:`use_async` Hook returns a function which can be called to
cancel the :code:`fn_coroutine` Task manually. In the example above,
the :code:`_cancel_fetcher()` function can be called to cancel the fetcher.
3. If the component is unmounted before the :code:`fn_coroutine` Task completes, then
the :code:`fn_coroutine` Task will be cancelled.
Write your async :code:`fn_coroutine` function in such a way that it
cleans itself up after exceptions. If you catch a :code:`CancelledError`
then always re-raise it.
You may call a :func:`use_state` setter during
a :code:`CancelledError` exception. If the :code:`fn_coroutine` Task was
cancelled because the component is being unmounted, then the
:func:`use_state` setter will have no effect.
Timers
======
The :code:`use_async` Hook is also useful for timers and animation.
Here is an example which shows how to run a timer in a component. The
Harmonic Oscillator in :doc:`../examples` uses this technique::
is_playing, is_playing_set = use_state(False)
play_trigger, play_trigger_set = use_state(False)
async def play_tick():
if is_playing:
# Do the timer effect here
# (timer effect code)
# Then wait for 0.05 seconds and trigger another play_tick.
await asyncio.sleep(0.05)
play_trigger_set(lambda p: not p)
use_async(play_tick, (is_playing, play_trigger))
Button(
text="pause" if is_playing else "play",
on_click=lambda e: is_playing_set(lambda p: not p),
)
Args:
fn_coroutine:
Async Coroutine function to be run as a Task.
dependencies:
The :code:`fn_coroutine` Task will be started when the
:code:`dependencies` are not :code:`__eq__` to the old :code:`dependencies`.
Returns:
A function which can be called to cancel the :code:`fn_coroutine` Task manually.
"""
context = get_render_context_maybe()
if context is None or context.current_element is None:
raise ValueError("use_async used outside component")
return context.engine.use_async(context.current_element, fn_coroutine, dependencies)
[docs]
def use_ref() -> Reference:
"""
Hook for creating a :class:`Reference` inside a :func:`@component<edifice.component>`
function.
"""
r, _ = use_state((Reference(),))
return r[0]
_P_callback = tp.ParamSpec("_P_callback")
[docs]
def use_callback(
fn: tp.Callable[[], tp.Callable[_P_callback, None]], dependencies: tp.Any,
) -> tp.Callable[_P_callback, None]:
"""
Hook for a callback function to pass as **props**.
This Hook behaves like React `useCallback <https://react.dev/reference/react/useCallback>`_.
Use this Hook to reduce the re-render frequency of
a :func:`@component<edifice.component>` which has a **prop** that is a function.
This Hook will return a callback function with specific
bound :code:`dependencies`
and the callback function will only change when the
specified :code:`dependencies` are not :code:`__eq__` to
the :code:`dependencies` from the last render, so the callback function
can be used as a stable **prop**.
The problem
===========
We want to present the user with a hundred buttons and give the buttons
an :code:`on_click` **prop**.
This :code:`SuperComp` component will re-render every time
the :code:`fastprop` **prop** changes, so then we will have to re-render all
the buttons even though the buttons don’t depend on the :code:`fastprop`.
.. code-block:: python
@component
def SuperComp(self, fastprop:int, slowprop:int):
value, value_set = use_state(0)
def value_from_slowprop():
value_set(slowprop)
for i in range(100):
Button(
on_click=lambda _event: value_from_slowprop()
)
The general solution to this kind of performance problem is to
create a new :func:`@component<component>` to render the
buttons. This new :code:`Buttons100` :func:`@component<component>`
will only re-render when its :code:`click_handler` **prop** changes.
.. code-block:: python
@component
def Buttons100(self, click_handler:Callable[[], None]):
for i in range(100):
Button(
on_click=lambda _event: click_handler()
)
@component
def SuperComp(self, fastprop:int, slowprop:int):
value, value_set = use_state(0)
def value_from_slowprop():
value_set(slowprop)
Buttons100(value_from_slowprop)
But there is a problem here, which is that the :code:`click_handler` **prop**
for the :code:`Buttons100` component is a new function
:code:`value_from_slowprop` every time that
the :code:`SuperComp` component re-renders, so it will always cause
the :code:`Buttons100` to re-render.
We can’t define :code:`value_from_slowprop` as a constant
function declared outside of the :code:`SuperComp` component because it
depends on bindings to :code:`slowprop` and :code:`value_set`.
The solution
============
So we use the :func:`use_callback` Hook to create a callback function
which only changes when :code:`slowprop` changes.
And now the :code:`Buttons100` will only re-render when the :code:`slowprop`
changes.
The :code:`value_set` **setter function** does not need to be in
the :code:`dependencies` because each **setter function** is always
:code:`__eq__` from the previous render.
.. code-block:: python
@component
def Buttons100(self, click_handler:Callable[[], None]):
for i in range(100):
Button(
on_click=lambda _event: click_handler(),
)
@component
def SuperComp(self, fastprop:int, slowprop:int):
value, value_set = use_state(0)
def make_value_from_slowprop():
def value_from_slowprop():
value_set(slowprop)
return value_from_slowprop
value_from_slowprop = use_callback(
make_value_from_slowprop,
(slowprop,),
)
Buttons100(value_from_slowprop)
Args:
fn:
A function of no arguments which creates and returns a callback function.
dependencies:
The callback function will be created when the
:code:`dependencies` are not :code:`__eq__` to the old :code:`dependencies`.
Returns:
A callback function.
"""
def initialize_f() -> tuple[tp.Callable[_P_callback, None], tp.Any]:
return (fn(), dependencies)
stored, stored_set = use_state(initialize_f)
if stored[1] != dependencies:
new_fn = fn()
stored_set((new_fn, dependencies))
return new_fn
return stored[0]
_P_async = ParamSpec("_P_async")
class _AsyncCommand(Generic[_P_async]):
def __init__(self, *args: _P_async.args, **kwargs: _P_async.kwargs):
self.args = args
self.kwargs = kwargs
[docs]
def use_async_call(
fn_coroutine: Callable[_P_async, Awaitable[None]],
) -> tuple[Callable[_P_async, None], Callable[[], None]]:
"""
Hook to call an async function from a non-async context.
The async :code:`fn_coroutine` function can have any argument
signature, but it must return :code:`None`. The return value is discarded.
The Hook takes an async function :code:`fn_coroutine` and returns a tuple
pair of non-async functions.
1. A non-async function with the same argument signature as the
:code:`fn_coroutine`. When called, this non-async function will start a
new Task an the main Edifice thread event loop as a :func:`use_async`
Hook which calls :code:`fn_coroutine`. This non-async function is
safe to call from any thread.
2. A non-async cancellation function which can be called to cancel
the :code:`fn_coroutine` Task manually. This cancellation function is
safe to call from any thread.
.. code-block:: python
:caption: use_async_call to delay print
async def delay_print_async(message:str):
await asyncio.sleep(1)
print(message)
delay_print, cancel_print = use_async_call(delay_print_async)
delay_print("Hello World")
Some time later, if we want to manually cancel the delayed print:
.. code-block:: python
:caption: cancel the delayed print
cancel_print()
This Hook is similar to :code:`useAsyncCallback` from
https://www.npmjs.com/package/react-async-hook
This Hook is similar to
`create_task() <https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.create_task>`_ ,
but because it uses
:func:`use_async`, it will cancel the Task
when this :func:`@component<edifice.component>` is unmounted, or when the function is called again.
Args:
fn_coroutine:
Async Coroutine function to be run as a Task.
Returns:
A tuple pair of non-async functions.
1. A non-async function with the same argument signature as the
:code:`fn_coroutine`.
2. A non-async cancellation function which can be called to cancel
the :code:`fn_coroutine` Task manually.
"""
triggered, triggered_set = use_state(cast(_AsyncCommand[_P_async] | None, None))
loop = get_event_loop()
def callback(*args: _P_async.args, **kwargs: _P_async.kwargs) -> None:
loop.call_soon_threadsafe(triggered_set, _AsyncCommand(*args, **kwargs))
async def wrapper():
if triggered is not None:
await fn_coroutine(*triggered.args, **triggered.kwargs)
cancel = use_async(wrapper, triggered)
def cancel_threadsafe() -> None:
loop.call_soon_threadsafe(cancel)
return callback, cancel_threadsafe
T = TypeVar("T")
[docs]
def use_effect_final(
cleanup: Callable[[], None],
dependencies: Any = (),
):
"""
Side-effect Hook for when a :func:`@component<edifice.component>` unmounts.
This Hook will call the :code:`cleanup` side-effect function with the latest
local state from :func:`use_state` Hooks.
This Hook solves the problem of using :func:`use_effect` with constant
deps to run a :code:`cleanup` function when a component unmounts. If the
:func:`use_effect` deps are constant so that the :code:`cleanup` function
only runs once, then the :code:`cleanup` function will not have a closure
on the latest component :code:`use_state` state.
This Hook :code:`cleanup` function will always have a closure on the
latest component :code:`use_state`.
The optional :code:`dependencies` argument can be used to trigger the
Hook to call the :code:`cleanup` function before the component unmounts.
.. code-block:: python
:caption: use_effect_final
x, set_x = ed.use_state(0)
def unmount_cleanup_x():
print(f"At unmount, the value of x is {x}")
use_effect_final(unmount_cleanup_x)
Debounce
--------
We can use this Hook together with :func:`use_async` to
`“debounce” <https://stackoverflow.com/questions/25991367/difference-between-throttling-and-debouncing-a-function>`_
an effect which must always finally run when the component unmounts.
.. code-block:: python
:caption: Debounce
x, set_x = ed.use_state(0)
# We want to save the value of x to a file whenever the value of
# x changes. But we don't want to do this too often because it would
# lag the GUI responses. Each use_async call will cancel prior
# awaiting calls. So this will save 1 second after the last change to x.
async def save_x_debounce():
await asyncio.sleep(1.0)
save_to_file(x)
use_async(save_x_debounce, x)
# And we want to make sure that the final value of x is saved to
# the file when the component unmounts.
# Unmounting the component will cancel the save_x_debounce Task,
# then the use_effect_final will save the final value of x.
use_effect_final(lambda: save_to_file(x))
"""
internal_mutable, _ = use_state(cast(list[Callable[[], None]], []))
# Always re-bind the cleanup function closed on the latest state
def bind_cleanup():
if len(internal_mutable) == 0:
internal_mutable.append(cleanup)
else:
internal_mutable[0] = cleanup
use_effect(bind_cleanup, None)
# This unmount function is called when the component unmounts
def unmount():
def internal_cleanup():
internal_mutable[0]()
return internal_cleanup
use_effect(unmount, dependencies)
[docs]
def use_hover() -> tuple[bool, tp.Callable[[QtGui.QMouseEvent], None], tp.Callable[[QtGui.QMouseEvent], None]]:
"""
Hook to track mouse hovering.
Use this hook to track if the mouse is hovering over a :class:`QtWidgetElement`.
.. code-block:: python
:caption: use_hover
hover, on_mouse_enter, on_mouse_leave = use_hover()
with VBoxView(
on_mouse_enter=on_mouse_enter,
on_mouse_leave=on_mouse_leave,
style={"background-color": "green"} if hover else {},
):
if hover:
Label(text="hovering")
The :code:`on_mouse_enter` and :code:`on_mouse_leave` functions can be
passed to more than one :class:`QtWidgetElement`.
Returns:
A tuple of three values:
1. :code:`bool`
True if the mouse is hovering over the
:class:`QtWidgetElement`, False otherwise.
2. :code:`Callable[[QtGui.QMouseEvent], None]`
Pass this function
to the :code:`on_mouse_enter` prop of the :class:`QtWidgetElement`
3. :code:`Callable[[QtGui.QMouseEvent], None]`
Pass this function
to the :code:`on_mouse_leave` prop of the :class:`QtWidgetElement`.
"""
hover, hover_set = use_state(False)
def on_mouse_enter(_ev: QtGui.QMouseEvent):
hover_set(True)
def on_mouse_leave(_ev: QtGui.QMouseEvent):
hover_set(False)
return hover, on_mouse_enter, on_mouse_leave