-
Notifications
You must be signed in to change notification settings - Fork 15
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: Toast Implementation #1030
base: main
Are you sure you want to change the base?
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
After using this a bit and then thinking about it a bit more, I gave poor guidance on adding the queue_event
to the RenderContext
. My thinking was that was the context we're rendering in, but toasts can be called outside of the render context; e.g. they're not rendered and are not part of the render itself. See on ElementMessageStream._process_callable_queue
; line 213 is where it calls all the callables in the _callable_queue
, then the call to .render()
(which subsequently sets the active render context) actually occurs on line 222.
All that to say I think we should have a separate context defined just for events, called EventContext
. Should have a get_event_context
/_set_event_context
like in RenderContext
, and an open
method that calls _set_event_context
(similar to how RenderContext.open calls
_set_context). Then in
ElementMessageStream, create the
EventContextin
init, and in
_process_callable_queueadd a
with self._event_contextafter the
with self.exec_context`.
Architecturally I think that makes more sense having the contexts separated, as they are not really the same.
on_press=ui.toast( | ||
"An update is available", | ||
action_label="Update", | ||
on_action=lambda: print("Updating!"), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add an on_close
here as well so it prints something when it is closed. Find it that it's mentioned above but isn't used.
plugins/ui/docs/components/toast.md
Outdated
|
||
## Events | ||
|
||
Toasts can include an optional action by specifying the `action_label` and `on_action` options when queueing a toast. In addition, the on_close event is triggered when the toast is dismissed. The `should_close_on_action` option automatically closes the toast when an action is performed. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Toasts can include an optional action by specifying the `action_label` and `on_action` options when queueing a toast. In addition, the on_close event is triggered when the toast is dismissed. The `should_close_on_action` option automatically closes the toast when an action is performed. | |
Toasts can include an optional action by specifying the `action_label` and `on_action` options when queueing a toast. In addition, the `on_close` event is triggered when the toast is dismissed. The `should_close_on_action` option automatically closes the toast when an action is performed. |
plugins/ui/docs/components/toast.md
Outdated
|
||
btn = ui.button( | ||
"Show toast", | ||
on_press=ui.toast("Toast is done!"), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mmm at some point when we were discussing this, we changed how this worked... calling ui.toast
shouldn't return a callback, it should trigger when you call it, so you have to have a lambda as the event handler:
on_press=lambda: ui.toast("Toasty!"),
plugins/ui/docs/components/toast.md
Outdated
|
||
## Show flag | ||
|
||
`ui.toast` returns a callback that shows a toast when a button is clicked. If you want to show the toast immediatley, set the `show` flag to `True`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As mentioned in above comment, I don't think it should return a callback, it should just trigger the toast when called. Also, I don't think calling toast = ui.toast("Show toast")
is what we want either - we assume each component is going to render at least one panel, so we open a panel while the component is still loading. By doing toast = ui.toast(...)
, it will open a panel in the loading state, and then immediately close it while showing the toast. I don't think that's good behaviour, and we should just simply not allow it.
What we can show though is showing a toast when a component is mounted. For example, showing a toast when a panel is opened and closed:
@ui.component
def ui_toast_on_mount():
def toast_effect():
ui.toast("Mounted.", variant="info")
return lambda: ui.toast("Unmounted.", variant="info")
ui.use_effect(lambda:
return ui.heading("Toast was shown on mount. Close this panel to show another toast.")
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have been trying to make this work. The unmount toast was not showing, and I thought it might need the render_queue:
from deephaven import ui
@ui.component
def ui_toast_on_mount():
render_queue = ui.use_render_queue()
def toast_effect():
ui.toast("Mounted.", variant="info")
return lambda: render_queue(lambda: ui.toast("Unmounted.", variant="info"))
ui.use_effect(lambda: toast_effect())
return ui.heading("Toast was shown on mount. Close this panel to show another toast.")
my_mount_example = ui_toast_on_mount()
When I close the panel, it prints an error:
ERROR:deephaven.ui.object_types.ElementMessageStream:io.deephaven.plugin.type.ObjectCommunicationException: java.lang.IllegalStateException: Stream is already completed, no further calls are allowed
Which is coming from the toast
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So, I think this is the code we want:
from deephaven import ui
@ui.component
def ui_toast_on_mount():
def toast_effect():
ui.toast("Mounted.", variant="info")
return ui.toast("Unmounted.", variant="info")
ui.use_effect(lambda: toast_effect())
return ui.heading("Toast was shown on mount. Close this panel to show another toast.")
my_mount_example = ui_toast_on_mount()
If I run it, I see both toasts pop up immediately and the EventContext is unmounted. This is all before I close the Panel. Do we unmount before closing the Panel?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I forgot the lambda
on the effect return. Yeah, the second toast
is called when the panel unmounts. But the event context unmounts immediately after the first toast is sent.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In other code (like when the toast is on a button). I see the context mount and unmount immediately. Then when I push the button, it mounts the event context, sends the toast, then unmounts again.
So our effect is not mounting the context.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And trying to use the render_queue does not fix it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tried calling _process_callable_queue()
in ElementMessageStream.on_close()
but it still executes before the use_effect call to the second toast.
plugins/ui/docs/components/toast.md
Outdated
|
||
## Toast from table example | ||
|
||
This example shows how to create a toast from the latest update of a ticking table. It is recommded to auto dismiss these toasts with a `timeout` and to avoid ticking faster than the value of the `timeout`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This example shows how to create a toast from the latest update of a ticking table. It is recommded to auto dismiss these toasts with a `timeout` and to avoid ticking faster than the value of the `timeout`. | |
This example shows how to create a toast from the latest update of a ticking table. It is recommended to auto dismiss these toasts with a `timeout` and to avoid ticking faster than the value of the `timeout`. |
|
||
my_toast_table = toast_table(_source) | ||
``` | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should show an example with multi-threading, and how it must be called from within the use_render_queue
callback. Modifying the multi-threading example in the main readme:
import logging
import threading
import time
from deephaven import read_csv, ui
@ui.component
def csv_loader():
# The render_queue we fetch using the `use_render_queue` hook at the top of the component
render_queue = ui.use_render_queue()
table, set_table = ui.use_state()
error, set_error = ui.use_state()
def handle_submit(data):
# We define a callable that we'll queue up on our own thread
def load_table():
try:
# Read the table from the URL
t = read_csv(data["url"])
# Define our state updates in another callable. We'll need to call this on the render thread
def update_state():
set_error(None)
set_table(t)
ui.toast("Table loaded", variant="positive", timeout=5000)
# Queue up the state update on the render thread
render_queue(update_state)
except Exception as e:
# In case we have any errors, we should show the error to the user. We still need to call this from the render thread,
# so we must assign the exception to a variable and call the render_queue with a callable that will set the error
error_message = e
def update_state():
set_table(None)
set_error(error_message)
ui.toast(f"Unable to load table: {error_message}", variant="negative", timeout=5000)
# Queue up the state update on the render thread
render_queue(update_state)
# Start our own thread loading the table
threading.Thread(target=load_table).start()
return [
# Our form displaying input from the user
ui.form(
ui.flex(
ui.text_field(
default_value="https://media.githubusercontent.com/media/deephaven/examples/main/DeNiro/csv/deniro.csv",
label="Enter URL",
label_position="side",
name="url",
flex_grow=1,
),
ui.button(f"Load Table", type="submit"),
gap=10,
),
on_submit=handle_submit,
),
(
# Display a hint if the table is not loaded yet and we don't have an error
ui.illustrated_message(
ui.heading("Enter URL above"),
ui.content("Enter a URL of a CSV above and click 'Load' to load it"),
)
if error is None and table is None
else None
),
# The loaded table. Doesn't show anything if it is not loaded yet
table,
# An error message if there is an error
(
ui.illustrated_message(
ui.icon("vsWarning"),
ui.heading("Error loading table"),
ui.content(f"{error}"),
)
if error != None
else None
),
]
my_loader = csv_loader()
@@ -33,6 +33,10 @@ | |||
Callable that is called when there is a change in the context (setting the state). | |||
""" | |||
|
|||
OnEventCallable = Callable[[str, dict], None] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Need to specify the type of dict here:
OnEventCallable = Callable[[str, dict], None] | |
OnEventCallable = Callable[[str, Dict[str, Any], None] |
@@ -485,6 +497,16 @@ def queue_render(self, update: Callable[[], None]) -> None: | |||
""" | |||
self._on_queue_render(update) | |||
|
|||
def queue_event(self, name: str, params: dict) -> None: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
def queue_event(self, name: str, params: dict) -> None: | |
def queue_event(self, name: str, params: Dict[str, Any]) -> None: |
@make_component | ||
def toast( | ||
message: str, | ||
variant: ToastVariant = "neutral", | ||
action_label: str | None = None, | ||
on_action: Callable[[], None] | None = None, | ||
should_close_on_action: bool | None = None, | ||
on_close: Callable[[], None] | None = None, | ||
timeout: int | None = None, | ||
id: str | None = None, | ||
show: bool = False, | ||
) -> Callable[[], None] | None: | ||
""" | ||
Toasts display brief, temporary notifications of actions, errors, or other events in an application. | ||
|
||
Args: | ||
message: The message to display in the toast. | ||
variant: The variant of the toast. Defaults to "neutral". | ||
action_label: The label for the action button with the toast. If provided, an action button will be displayed. | ||
on_action: Handler that is called when the action button is pressed. | ||
should_close_on_action: Whether the toast should automatically close when an action is performed. | ||
on_close: Handler that is called when the toast is closed, either by the user or after a timeout. | ||
timeout: A timeout to automatically close the toast after, in milliseconds. | ||
id: The element's unique identifier. | ||
show: True if the toast should be displayed immediately, False to wait for a later call. | ||
|
||
Returns: | ||
A callback to show the toast or None if show is True. | ||
""" | ||
local_params = locals() | ||
del local_params["show"] | ||
params = dict_to_react_props(local_params) | ||
event_queue = use_event_queue() | ||
if show: | ||
event_queue(_TOAST_EVENT, params) | ||
else: | ||
return lambda: event_queue(_TOAST_EVENT, params) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As mentioned in my other comment, we shouldn't need the show
prop. We also don't need to add @make_component
here.
@make_component | |
def toast( | |
message: str, | |
variant: ToastVariant = "neutral", | |
action_label: str | None = None, | |
on_action: Callable[[], None] | None = None, | |
should_close_on_action: bool | None = None, | |
on_close: Callable[[], None] | None = None, | |
timeout: int | None = None, | |
id: str | None = None, | |
show: bool = False, | |
) -> Callable[[], None] | None: | |
""" | |
Toasts display brief, temporary notifications of actions, errors, or other events in an application. | |
Args: | |
message: The message to display in the toast. | |
variant: The variant of the toast. Defaults to "neutral". | |
action_label: The label for the action button with the toast. If provided, an action button will be displayed. | |
on_action: Handler that is called when the action button is pressed. | |
should_close_on_action: Whether the toast should automatically close when an action is performed. | |
on_close: Handler that is called when the toast is closed, either by the user or after a timeout. | |
timeout: A timeout to automatically close the toast after, in milliseconds. | |
id: The element's unique identifier. | |
show: True if the toast should be displayed immediately, False to wait for a later call. | |
Returns: | |
A callback to show the toast or None if show is True. | |
""" | |
local_params = locals() | |
del local_params["show"] | |
params = dict_to_react_props(local_params) | |
event_queue = use_event_queue() | |
if show: | |
event_queue(_TOAST_EVENT, params) | |
else: | |
return lambda: event_queue(_TOAST_EVENT, params) | |
def toast( | |
message: str, | |
variant: ToastVariant = "neutral", | |
action_label: str | None = None, | |
on_action: Callable[[], None] | None = None, | |
should_close_on_action: bool | None = None, | |
on_close: Callable[[], None] | None = None, | |
timeout: int | None = None, | |
id: str | None = None, | |
show: bool = False, | |
) -> Callable[[], None] | None: | |
""" | |
Toasts display brief, temporary notifications of actions, errors, or other events in an application. | |
Args: | |
message: The message to display in the toast. | |
variant: The variant of the toast. Defaults to "neutral". | |
action_label: The label for the action button with the toast. If provided, an action button will be displayed. | |
on_action: Handler that is called when the action button is pressed. | |
should_close_on_action: Whether the toast should automatically close when an action is performed. | |
on_close: Handler that is called when the toast is closed, either by the user or after a timeout. | |
timeout: A timeout to automatically close the toast after, in milliseconds. | |
id: The element's unique identifier. | |
Returns: | |
A callback to show the toast or None if show is True. | |
""" | |
params = dict_to_react_props(locals()) | |
event_queue = use_event_queue() | |
event_queue(_TOAST_EVENT, params) |
encoded_params, callable_id_dict = self._encoder.encode_event_params(params) | ||
# Register any new callables | ||
for callable, callable_id in callable_id_dict.items(): | ||
if callable_id not in self._callable_dict: | ||
logger.debug("Registering callable %s", callable_id) | ||
self._callable_dict[callable_id] = wrap_callable(callable) | ||
request = self._make_notification("event", name, encoded_params) | ||
payload = json.dumps(request) | ||
self._connection.on_data(payload.encode(), []) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm this is going to require a bit more thought. Right now this callable will only be alive until the next render, which may not be what the user expects... instead it should work like _call_callable
does, where we add a temporary callable; then the client needs to delete the callable by calling closeCallable
when it's done (see WidgetHandler#callableFinalizationRegistry
).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So, I should add these callables to _temp_callable_dict
?
Then in WidgetHandler
, I call wrapCallable
which adds them to the registry, which should call closeCallable
when they are GC'd?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes that should do the trick.
@make_component | ||
def toast( | ||
message: str, | ||
variant: ToastVariant = "neutral", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should treat the rest of the arguments as keyword args only:
variant: ToastVariant = "neutral", | |
*, | |
variant: ToastVariant = "neutral", |
closes #410
closes #874