33import warnings
44from abc import ABC , abstractmethod
55from pathlib import Path
6- from typing import TYPE_CHECKING , Awaitable , Callable , Literal , NoReturn , Optional
7-
8- from htmltools import TagChild
6+ from typing import TYPE_CHECKING , Awaitable , Callable , Literal
97
108from .._docstring import add_example
119from .._utils import AsyncCallbacks , CancelCallback , wrap_async
@@ -34,6 +32,7 @@ class Bookmark(ABC):
3432
3533 _on_get_exclude : list [Callable [[], list [str ]]]
3634 """Callbacks that BookmarkProxy classes utilize to help determine the list of inputs to exclude from bookmarking."""
35+
3736 exclude : list [str ]
3837 """A list of scoped Input names to exclude from bookmarking."""
3938
@@ -129,15 +128,14 @@ def on_bookmarked(
129128
130129 This callback will be executed **after** the bookmark state is saved serverside or in the URL.
131130
132- Note: `.on_bookmarked()` can only be added to the `AppSession` object, not on a a module's session.
133-
134131 Parameters
135132 ----------
136133 callback
137134 The callback function to call when the session is bookmarked. This method
138135 should accept a single argument, the string representing the query parameter
139136 component of the URL.
140137 """
138+
141139 return self ._on_bookmarked_callbacks .register (wrap_async (callback ))
142140
143141 def on_restore (
@@ -313,65 +311,6 @@ async def show_bookmark_url_modal(
313311 return
314312
315313
316- # #' Generate a modal dialog that displays a URL
317- # #'
318- # #' The modal dialog generated by `urlModal` will display the URL in a
319- # #' textarea input, and the URL text will be selected so that it can be easily
320- # #' copied. The result from `urlModal` should be passed to the
321- # #' [showModal()] function to display it in the browser.
322- # #'
323- # #' @param url A URL to display in the dialog box.
324- # #' @param title A title for the dialog box.
325- # #' @param subtitle Text to display underneath URL.
326- # #' @export
327- # urlModal <- function(url, title = "Bookmarked application link", subtitle = NULL) {
328-
329- # subtitleTag <- tagList(
330- # br(),
331- # span(class = "text-muted", subtitle),
332- # span(id = "shiny-bookmark-copy-text", class = "text-muted")
333- # )
334-
335- # modalDialog(
336- # title = title,
337- # easyClose = TRUE,
338- # tags$textarea(class = "form-control", rows = "1", style = "resize: none;",
339- # readonly = "readonly",
340- # url
341- # ),
342- # subtitleTag,
343- # # Need separate show and shown listeners. The show listener sizes the
344- # # textarea just as the modal starts to fade in. The 200ms delay is needed
345- # # because if we try to resize earlier, it can't calculate the text height
346- # # (scrollHeight will be reported as zero). The shown listener selects the
347- # # text; it's needed because because selection has to be done after the fade-
348- # # in is completed.
349- # tags$script(
350- # "$('#shiny-modal').
351- # one('show.bs.modal', function() {
352- # setTimeout(function() {
353- # var $textarea = $('#shiny-modal textarea');
354- # $textarea.innerHeight($textarea[0].scrollHeight);
355- # }, 200);
356- # });
357- # $('#shiny-modal')
358- # .one('shown.bs.modal', function() {
359- # $('#shiny-modal textarea').select().focus();
360- # });
361- # $('#shiny-bookmark-copy-text')
362- # .text(function() {
363- # if (/Mac/i.test(navigator.userAgent)) {
364- # return 'Press \u2318-C to copy.';
365- # } else {
366- # return 'Press Ctrl-C to copy.';
367- # }
368- # });
369- # "
370- # )
371- # )
372- # }
373-
374-
375314class BookmarkApp (Bookmark ):
376315 _root_session : AppSession
377316 """
@@ -507,38 +446,6 @@ async def invoke_on_restored_callbacks():
507446
508447 return
509448
510- def on_bookmark (
511- self ,
512- callback : (
513- Callable [[BookmarkState ], None ] | Callable [[BookmarkState ], Awaitable [None ]]
514- ),
515- / ,
516- ) -> CancelCallback :
517- return self ._on_bookmark_callbacks .register (wrap_async (callback ))
518-
519- def on_bookmarked (
520- self ,
521- callback : Callable [[str ], None ] | Callable [[str ], Awaitable [None ]],
522- / ,
523- ) -> CancelCallback :
524- return self ._on_bookmarked_callbacks .register (wrap_async (callback ))
525-
526- def on_restore (
527- self ,
528- callback : (
529- Callable [[RestoreState ], None ] | Callable [[RestoreState ], Awaitable [None ]]
530- ),
531- ) -> CancelCallback :
532- return self ._on_restore_callbacks .register (wrap_async (callback ))
533-
534- def on_restored (
535- self ,
536- callback : (
537- Callable [[RestoreState ], None ] | Callable [[RestoreState ], Awaitable [None ]]
538- ),
539- ) -> CancelCallback :
540- return self ._on_restored_callbacks .register (wrap_async (callback ))
541-
542449 async def update_query_string (
543450 self ,
544451 query_string : str ,
@@ -564,7 +471,7 @@ def _get_bookmark_exclude(self) -> list[str]:
564471 for proxy_exclude_fn in self ._on_get_exclude :
565472 scoped_excludes .extend (proxy_exclude_fn ())
566473 # Remove duplicates
567- return list ( set ([* self .exclude , * scoped_excludes ]))
474+ return [ str ( name ) for name in set ([* self .exclude , * scoped_excludes ])]
568475
569476 async def get_bookmark_url (self ) -> str | None :
570477 if self .store == "disable" :
@@ -644,7 +551,7 @@ async def do_bookmark(self) -> None:
644551 # Barret:
645552 # This action feels weird. I don't believe it should occur
646553 # Instead, I believe it should update the query string and set the user's clipboard with a UI notification in the corner.
647- with session_context (self ._session ):
554+ with session_context (self ._root_session ):
648555 await self .show_bookmark_url_modal (full_url )
649556
650557 except Exception as e :
@@ -674,56 +581,23 @@ def __init__(self, session_proxy: SessionProxy):
674581 self ._proxy_session = session_proxy
675582 self ._root_session = session_proxy ._root_session
676583
677- # Maybe `._get_bookmark_exclude()` should be used instead of`proxy_exclude_fns`?
584+ # Be sure root bookmark has access to proxy's excluded values
678585 self ._root_bookmark ._on_get_exclude .append (
679586 lambda : [str (self ._ns (name )) for name in self .exclude ]
680587 )
681588
682- # When scope is created, register these bookmarking callbacks on the main
683- # session object. They will invoke the scope's own callbacks, if any are
684- # present.
685-
686- # The goal of this method is to save the scope's values. All namespaced inputs
687- # will already exist within the `root_state`.
688- self ._root_bookmark .on_bookmark (self ._scoped_on_bookmark )
689-
690- from ..session import session_context
691-
692- # # Disabling `on_bookmarked` within BookmarkProxy so we can tell when
693- # # the user has not registered a `on_bookmarked` callback.
694- # @self._root_bookmark.on_bookmarked
695- # async def scoped_on_bookmarked(url: str) -> None:
696- # if self._on_bookmarked_callbacks.count() == 0:
697- # return
698- # with session_context(self._proxy_session):
699- # await self._on_bookmarked_callbacks.invoke(url)
700-
701- ns_prefix = str (self ._ns + self ._ns ._sep )
702-
703- @self ._root_bookmark .on_restore
704- async def scoped_on_restore (restore_state : RestoreState ) -> None :
705- if self ._on_restore_callbacks .count () == 0 :
706- return
707-
708- scoped_restore_state = restore_state ._state_within_namespace (ns_prefix )
709-
710- with session_context (self ._proxy_session ):
711- await self ._on_restore_callbacks .invoke (scoped_restore_state )
712-
713- @self ._root_bookmark .on_restored
714- async def scoped_on_restored (restore_state : RestoreState ) -> None :
715- if self ._on_restored_callbacks .count () == 0 :
716- return
589+ # Note: This proxy bookmark class will not register a handler (`on_bookmark`,
590+ # `on_bookmarked`, `on_restore`, `on_restored`) until one is requested either by
591+ # the app author or a sub-proxy bookmark class
592+ # These function are utilized
717593
718- scoped_restore_state = restore_state . _state_within_namespace ( ns_prefix )
719- with session_context (self . _proxy_session ) :
720- await self ._on_restored_callbacks . invoke ( scoped_restore_state )
594+ @ property
595+ def _ns_prefix (self ) -> str :
596+ return str ( self . _ns + self ._ns . _sep )
721597
598+ # The goal of this method is to save the scope's values. All namespaced inputs
599+ # will already exist within the `root_state`.
722600 async def _scoped_on_bookmark (self , root_state : BookmarkState ) -> None :
723- # Exit if no user-defined callbacks.
724- if self ._on_bookmark_callbacks .count () == 0 :
725- return
726-
727601 from ..bookmark ._bookmark import BookmarkState
728602
729603 scoped_state = BookmarkState (
@@ -737,14 +611,19 @@ async def _scoped_on_bookmark(self, root_state: BookmarkState) -> None:
737611 if root_state .dir is not None :
738612 scope_subpath = self ._ns
739613 scoped_state .dir = Path (root_state .dir ) / scope_subpath
614+ # Barret - 2025-03-17:
615+ # Having Shiny make this directory feels like the wrong thing to do here.
616+ # Feels like we should be using the App's bookmark_save_dir function to
617+ # determine where to save the bookmark state.
618+ # However, R-Shiny currently creates the directory:
619+ # https://github.com/rstudio/shiny/blob/f55c26af4a0493b082d2967aca6d36b90795adf1/R/shiny.R#L940
740620 scoped_state .dir .mkdir (parents = True , exist_ok = True )
741621
742622 if not scoped_state .dir .exists ():
743623 raise FileNotFoundError (
744624 f"Scope directory could not be created for { scope_subpath } "
745625 )
746626
747- # Invoke the callback on the scopeState object
748627 from ..session import session_context
749628
750629 with session_context (self ._proxy_session ):
@@ -757,11 +636,83 @@ async def _scoped_on_bookmark(self, root_state: BookmarkState) -> None:
757636 raise ValueError ("All scope values must be named." )
758637 root_state .values [str (self ._ns (key ))] = value
759638
639+ def on_bookmark (
640+ self ,
641+ callback : (
642+ Callable [[BookmarkState ], None ] | Callable [[BookmarkState ], Awaitable [None ]]
643+ ),
644+ ) -> CancelCallback :
645+ if self ._on_bookmark_callbacks .count () == 0 :
646+ # Register a proxy callback on the parent session
647+ self ._root_bookmark .on_bookmark (self ._scoped_on_bookmark )
648+ return super ().on_bookmark (callback )
649+
760650 def on_bookmarked (
761651 self ,
762652 callback : Callable [[str ], None ] | Callable [[str ], Awaitable [None ]],
763- ) -> NoReturn :
764- raise RuntimeError ("`on_bookmarked` is not supported in BookmarkProxy." )
653+ ) -> CancelCallback :
654+ if self ._on_bookmarked_callbacks .count () == 0 :
655+ from ..session import session_context
656+
657+ # Register a proxy callback on the parent session
658+ @self ._root_bookmark .on_bookmarked
659+ async def scoped_on_bookmarked (url : str ) -> None :
660+ if self ._on_bookmarked_callbacks .count () == 0 :
661+ return
662+ with session_context (self ._proxy_session ):
663+ await self ._on_bookmarked_callbacks .invoke (url )
664+
665+ return super ().on_bookmarked (callback )
666+
667+ def on_restore (
668+ self ,
669+ callback : (
670+ Callable [[RestoreState ], None ] | Callable [[RestoreState ], Awaitable [None ]]
671+ ),
672+ ) -> CancelCallback :
673+
674+ if self ._on_restore_callbacks .count () == 0 :
675+ from ..session import session_context
676+
677+ # Register a proxy callback on the parent session
678+ @self ._root_bookmark .on_restore
679+ async def scoped_on_restore (restore_state : RestoreState ) -> None :
680+ if self ._on_restore_callbacks .count () == 0 :
681+ return
682+
683+ scoped_restore_state = restore_state ._state_within_namespace (
684+ self ._ns_prefix
685+ )
686+
687+ with session_context (self ._proxy_session ):
688+ await self ._on_restore_callbacks .invoke (scoped_restore_state )
689+
690+ return super ().on_restore (callback )
691+
692+ def on_restored (
693+ self ,
694+ callback : (
695+ Callable [[RestoreState ], None ] | Callable [[RestoreState ], Awaitable [None ]]
696+ ),
697+ ) -> CancelCallback :
698+
699+ if self ._on_restored_callbacks .count () == 0 :
700+ from ..session import session_context
701+
702+ # Register a proxy callback on the parent session
703+ @self ._root_bookmark .on_restored
704+ async def scoped_on_restored (restore_state : RestoreState ) -> None :
705+ if self ._on_restored_callbacks .count () == 0 :
706+ return
707+
708+ scoped_restore_state = restore_state ._state_within_namespace (
709+ self ._ns_prefix
710+ )
711+
712+ with session_context (self ._proxy_session ):
713+ await self ._on_restored_callbacks .invoke (scoped_restore_state )
714+
715+ return super ().on_restored (callback )
765716
766717 @property
767718 def store (self ) -> BookmarkStore :
0 commit comments