| Title: | Undo and Redo for 'shiny' Applications |
|---|---|
| Description: | Adds a user-facing undo and redo history to 'shiny' applications. Application state, comprising registered inputs and optionally server-side reactive values, is captured as the user interacts with the application. Users may then step backwards and forwards through that history with the keyboard, with buttons, or by scrubbing a visual history rail. Rapid successive changes, such as dragging a slider, are coalesced into a single history entry, and related changes may be grouped explicitly into semantic steps. |
| Authors: | Tanmay Chanda [aut, cre, cph] |
| Maintainer: | Tanmay Chanda <[email protected]> |
| License: | MIT + file LICENSE |
| Version: | 0.3.0.9000 |
| Built: | 2026-10-12 15:55:02 UTC |
| Source: | https://github.com/tenmeh/rewind |
These two buttons connect to the history of the session. They become enabled and disabled as the history permits. You thus do not have to write an observer on the server.
rewind_buttons( undo_label = "Undo", redo_label = "Redo", class = NULL, button_class = NULL )rewind_buttons( undo_label = "Undo", redo_label = "Redo", class = NULL, button_class = NULL )
undo_label, redo_label
|
The button labels. Use |
class |
More CSS classes for the container element. |
button_class |
More CSS classes for the two buttons. Use it for a
Bootstrap variant such as |
The buttons carry btn btn-default, so a Bootstrap theme applies to
them without any work. Use button_class to give them a different
Bootstrap variant, size or shape:
rewind_buttons(button_class = "btn-primary") rewind_buttons(button_class = "btn-outline-secondary btn-sm")
A variant that you add wins over btn-default, so the buttons take the
colours of the theme of your application. bslib::bs_theme() decides
what those colours are. rewind thus has no colours of its own to keep
in agreement with your application.
rewind_buttons() rewind_buttons(undo_label = NULL, redo_label = NULL) rewind_buttons(button_class = "btn-outline-primary btn-sm")rewind_buttons() rewind_buttons(undo_label = NULL, redo_label = NULL) rewind_buttons(button_class = "btn-outline-primary btn-sm")
rewind_enable() adds this automatically. You thus rarely need this
function. Use it when you want the assets before the server function
runs. Use it also when you cannot use insertUI().
rewind_dependency()rewind_dependency()
An htmltools::htmlDependency().
rewind_dependency()rewind_dependency()
rewind_history() gives one row for each step, with a label such as
"region, year". That label says which values changed. This function
gives the values themselves: what each one held before, and what it
holds now.
rewind_diff( from = NULL, to = NULL, session = shiny::getDefaultReactiveDomain() )rewind_diff( from = NULL, to = NULL, session = shiny::getDefaultReactiveDomain() )
from, to
|
The two positions to compare, as in the |
session |
The Shiny session. The default is the current session. |
Use it to tell the user what an undo will do, or to write your own record of what a person changed.
A data frame with one row for each value that differs, in the order of the names. It has seven columns:
from, to: the two positions compared.
name: the input ID, or id$field for a tracked value.
change: a short description that a person can read.
old, new: list columns that hold the values themselves.
label: the name a person reads, from rewind_enable(labels = ).
It is the same as name when there is no label. It is the last
column because it was added in 0.4.0, after the other six.
The data frame has no rows when nothing differs, and when there is no
earlier step to compare with. It depends on the history, so you can
use it in render*() and observe().
The history lives in the memory of one session, and it goes when the
session goes. It also drops the oldest entries when it passes depth,
and it drops the steps in front of the position when a change arrives
after an undo. A record for an inspection must be written as each change
occurs, and kept somewhere else. The history is reactive, so:
observeEvent(rewind_history(), {
write_my_audit_row(rewind_diff())
})
The name column holds the input ID, such as region. It does not hold
the label of the widget, because Shiny does not give the server the
label of an input. A value that rewind_track() records appears as
id$field.
old and new are list columns, so they hold the values themselves,
whatever their type. The change column is a short description for a
person to read. It shows both values when they are short, such as
"North -> South", and a count when they are not, such as
"12 values".
if (interactive()) { library(shiny) ui <- fluidPage( rewind_buttons(), selectInput("region", "Region", c("North", "South", "East")), sliderInput("year", "Years", 2018, 2026, c(2018, 2026), sep = ""), tableOutput("changed") ) server <- function(input, output, session) { rewind_enable() # What did the last step change? output$changed <- renderTable({ rewind_diff()[, c("name", "change")] }) } shinyApp(ui, server) }if (interactive()) { library(shiny) ui <- fluidPage( rewind_buttons(), selectInput("region", "Region", c("North", "South", "East")), sliderInput("year", "Years", 2018, 2026, c(2018, 2026), sep = ""), tableOutput("changed") ) server <- function(input, output, session) { rewind_enable() # What did the last step change? output$changed <- renderTable({ rewind_diff()[, c("name", "change")] }) } shinyApp(ui, server) }
rewind_pause() stops capture for a short time. It needs a
rewind_resume() call after it. This function is different. It removes
everything that rewind_enable() made:
rewind_disable(session = shiny::getDefaultReactiveDomain())rewind_disable(session = shiny::getDefaultReactiveDomain())
session |
The Shiny session. The default is the current session. |
it destroys each observer;
it sets the buttons and the history rail in the browser to their empty, disabled condition;
it returns the session to the condition before rewind_enable().
Call rewind_enable() again to start a new history.
Use this function when the application permits undo and redo only in some conditions. An example is a user role that the application does not know at the start of the session.
TRUE if the function disabled a session. FALSE if rewind
was not enabled. The function returns the value invisibly.
if (interactive()) { library(shiny) server <- function(input, output, session) { rewind_enable() observeEvent(input$readonly_mode, { if (input$readonly_mode) rewind_disable() }) } }if (interactive()) { library(shiny) server <- function(input, output, session) { rewind_enable() observeEvent(input$readonly_mode, { if (input$readonly_mode) rewind_disable() }) } }
Call this once, near the top of your server function. After that,
rewind records the session inputs as the user works. It also records
the reactive values that you register with rewind_track(). The user can
then move backwards and forwards through that history.
rewind_enable( session = shiny::getDefaultReactiveDomain(), inputs = NULL, exclude = NULL, depth = 50L, coalesce_ms = 400L, restore_timeout = 2, shortcuts = TRUE, verbose = FALSE, labels = NULL )rewind_enable( session = shiny::getDefaultReactiveDomain(), inputs = NULL, exclude = NULL, depth = 50L, coalesce_ms = 400L, restore_timeout = 2, shortcuts = TRUE, verbose = FALSE, labels = NULL )
session |
The Shiny session. The default is the current session. |
inputs |
Character vector of the input IDs to capture. Use |
exclude |
Character vector of input IDs to skip. |
depth |
The maximum number of history entries to keep. |
coalesce_ms |
The quiet period in milliseconds before |
restore_timeout |
The time in seconds that Increase it for an application on a slow connection, such as one behind a VPN or on a mobile network. If the limit is too short, capture starts again while the browser is still applying the restore, and a partial state becomes a history entry that the user never made. Do not increase it more than you need. If a restore never returns,
|
shortcuts |
Set to
The shortcuts do nothing while the user types in a text field. The text undo of the browser thus continues to work. |
verbose |
Set to |
labels |
A named character vector of the names a person reads,
such as |
The controller, invisibly. Most applications can ignore it.
By default rewind captures every input that the page can put back: an
input whose element has a Shiny input binding. That covers the inputs of
Shiny, and those of other packages that give their widgets a binding.
It leaves out inputs that have no such element. A plot's hover,
click, dblclick and brush, and the values that DT keeps, such as
_rows_current, _rows_all and _state, are sent to the server with
Shiny.setInputValue() alone. An undo cannot restore them, so recording
them made steps that seemed to do nothing. The browser reports which
inputs have a binding, including those that renderUI() adds later.
Until that report arrives, and in shiny::testServer(), which has no
browser, every input is captured.
To capture an input the page cannot restore, such as the selected rows
of a DT table, name it in inputs. A list in inputs is used as it is.
Use rewind_status() to see what is captured, and why an input is not.
Five more kinds of input are never captured:
action buttons and links. Their value is a click counter.
shiny::fileInput(). Its value points to a temporary file on the
server. Shiny deletes that file at the next upload. An old snapshot
would thus point to a file that does not exist.
password fields, such as shiny::passwordInput(). A password must not
be kept in the history, put back in a field by an undo, or shown by
rewind_diff(). The server cannot tell a password from other text, so
the browser reports which fields are passwords, including fields that
renderUI() adds later. This exclusion applies even when inputs
names the field. In shiny::testServer() there is no browser, so a
password field there is captured like any other input.
inputs with names that start with rewind_. These belong to this
package.
inputs with names that start with .. These are internal to Shiny.
A word typed at a normal speed is one step. A pause longer than
coalesce_ms starts a new step, so slow typing can give several. For a
text box, textInput(updateOn = "blur") gives exactly one step for each
edit, when the user leaves the box.
While the cursor is in a text box, Ctrl + Z is the browser's own text
undo, and rewind does nothing (refer to shortcuts below). The
browser's undo still changes the box, so rewind records the change as
a new step. To step back through the history, leave the box first, or
use the buttons.
Use inputs to give a list of the inputs to capture, and exclude or
rewind_exclude() to leave some out. A list is usually better in a large
application. Undo must move the controls that the user
thinks of as filters. It must not move every other input on the page.
Changes that occur within coalesce_ms of each other become one history
entry. One drag of a slider is thus one undo step, not forty. Use
rewind_step() to group changes yourself.
You can call this function inside a moduleServer(). rewind captures
the inputs with their module-local names. These are the same names that
input$ uses inside the module. rewind adds the namespace with
session$ns() when it restores them.
All modules share session$userData. A second call to rewind_enable()
thus uses the same history as the first call. It does not make a second
history. Call the function once, at the position in the module tree that
is best for your application.
if (interactive()) { library(shiny) ui <- fluidPage( rewind_buttons(), selectInput("species", "Species", c("setosa", "versicolor", "virginica")), sliderInput("n", "Rows", 1, 50, 10), tableOutput("tbl") ) server <- function(input, output, session) { rewind_enable() output$tbl <- renderTable({ head(iris[iris$Species == input$species, ], input$n) }) } shinyApp(ui, server) }if (interactive()) { library(shiny) ui <- fluidPage( rewind_buttons(), selectInput("species", "Species", c("setosa", "versicolor", "virginica")), sliderInput("n", "Rows", 1, 50, 10), tableOutput("tbl") ) server <- function(input, output, session) { rewind_enable() output$tbl <- renderTable({ head(iris[iris$Species == input$species, ], input$n) }) } shinyApp(ui, server) }
rewind_enable(exclude = ) leaves inputs out, but only the code that
calls rewind_enable() can set it. This function can be called from
anywhere in the server, a module included, at any time. It works like
shiny::setBookmarkExclude(): inside a module, give the names that the
module uses, and rewind adds the namespace.
rewind_exclude( names = character(0), session = shiny::getDefaultReactiveDomain() )rewind_exclude( names = character(0), session = shiny::getDefaultReactiveDomain() )
names |
Character vector of input names, as the calling module uses them. |
session |
The Shiny session. The default is the current session. |
Two things differ from setBookmarkExclude(). A second call adds to the
list; it does not replace it, so a module cannot undo an exclusion made
elsewhere. And a value already in the history for these inputs is
removed, together with any step that changed only them.
A call before rewind_enable() in the same session is kept, and applied
when rewind_enable() runs.
names, invisibly.
if (interactive()) { library(shiny) search_ui <- function(id) textInput(NS(id, "query"), "Search") search_server <- function(id) { moduleServer(id, function(input, output, session) { # A search box that the user does not expect undo to move. rewind_exclude("query") }) } ui <- fluidPage( rewind_buttons(), selectInput("region", "Region", c("North", "South")), search_ui("search") ) server <- function(input, output, session) { rewind_enable() search_server("search") } shinyApp(ui, server) }if (interactive()) { library(shiny) search_ui <- function(id) textInput(NS(id, "query"), "Search") search_server <- function(id) { moduleServer(id, function(input, output, session) { # A search box that the user does not expect undo to move. rewind_exclude("query") }) } ui <- fluidPage( rewind_buttons(), selectInput("region", "Region", c("North", "South")), search_ui("search") ) server <- function(input, output, session) { rewind_enable() search_server("search") } shinyApp(ui, server) }
rewind_history() gives one row for each entry that rewind keeps.
rewind_can_undo() and rewind_can_redo() tell you if the position can
change. All three functions depend on the history. You can thus use them
in render*() and observe() to control your own UI.
rewind_history(session = shiny::getDefaultReactiveDomain()) rewind_can_undo(session = shiny::getDefaultReactiveDomain()) rewind_can_redo(session = shiny::getDefaultReactiveDomain())rewind_history(session = shiny::getDefaultReactiveDomain()) rewind_can_undo(session = shiny::getDefaultReactiveDomain()) rewind_can_redo(session = shiny::getDefaultReactiveDomain())
session |
The Shiny session. The default is the current session. |
rewind_history() gives a data frame. It has the columns index,
label, time and current. The other two functions give one logical
value.
if (interactive()) { library(shiny) server <- function(input, output, session) { rewind_enable() output$steps <- renderTable(rewind_history()) } }if (interactive()) { library(shiny) server <- function(input, output, session) { rewind_enable() output$steps <- renderTable(rewind_history()) } }
While capture is paused, the application applies changes as usual. But
rewind does not record them. Use this to stop capture for a period
that spans more than one reactive flush, such as while the user works
in a part of the application that undo must not cover.
rewind_pause(session = shiny::getDefaultReactiveDomain()) rewind_resume(session = shiny::getDefaultReactiveDomain())rewind_pause(session = shiny::getDefaultReactiveDomain()) rewind_resume(session = shiny::getDefaultReactiveDomain())
session |
The Shiny session. The default is the current session. |
TRUE, invisibly.
A pause and a resume in the same observer, or the same observeEvent(),
have no effect. rewind captures in its own observer, which runs after
the one that made the change. By then capture has resumed, so the change
is recorded as usual. A change made with update*Input() also comes
back from the browser later.
For code that changes the application and must not make a step, such as
code that restores a saved view, use rewind_unrecorded().
if (interactive()) { library(shiny) server <- function(input, output, session) { rewind_enable() # Stop capture while an "expert mode" switch is on. observeEvent(input$expert_mode, { if (input$expert_mode) rewind_pause() else rewind_resume() }) } }if (interactive()) { library(shiny) server <- function(input, output, session) { rewind_enable() # Stop capture while an "expert mode" switch is on. observeEvent(input$expert_mode, { if (input$expert_mode) rewind_pause() else rewind_resume() }) } }
An undo that skips an input is hard to explain from outside. The input
can be excluded, can be a password field, can be missing from inputs,
or can be one that the page cannot put back. This function lists every
input of the session with the reason, so one call answers the question.
rewind_status(session = shiny::getDefaultReactiveDomain())rewind_status(session = shiny::getDefaultReactiveDomain())
session |
The Shiny session. The default is the current session. |
It reads every input, so inside render*() the result follows the
inputs and the history. That makes it useful in a debug panel, for
example renderPrint(rewind_status()).
A list:
captured: the names of the inputs that rewind records.
skipped: a named character vector. Each name is an input, and each
value is the reason it is not recorded: "excluded",
"password field", "not in inputs",
"cannot be restored by the page", "action button",
"file upload" or "NULL value". Inputs internal to Shiny and to
rewind are left out of the list.
tracked: the groups registered with rewind_track(), each with the
fields it names (NULL for all fields).
restorable: the inputs that the page reported it can restore, or
NULL before the report arrives. Refer to rewind_enable().
paused: TRUE between rewind_pause() and rewind_resume().
size, position: the number of steps, and the current one.
if (interactive()) { library(shiny) ui <- fluidPage( rewind_buttons(), selectInput("region", "Region", c("North", "South")), passwordInput("pw", "Password"), verbatimTextOutput("status") ) server <- function(input, output, session) { rewind_enable() output$status <- renderPrint(rewind_status()$skipped) } shinyApp(ui, server) }if (interactive()) { library(shiny) ui <- fluidPage( rewind_buttons(), selectInput("region", "Region", c("North", "South")), passwordInput("pw", "Password"), verbatimTextOutput("status") ) server <- function(input, output, session) { rewind_enable() output$status <- renderPrint(rewind_status()$skipped) } shinyApp(ui, server) }
This function holds a block of code. Every change in that block becomes one history entry with the label that you give. Use it for buttons such as "reset all filters" or "apply preset". For these buttons, the standard time-based grouping can make several steps, or it can give a label that does not help the user.
rewind_step( expr, label = NULL, hold_ms = NULL, session = shiny::getDefaultReactiveDomain() )rewind_step( expr, label = NULL, hold_ms = NULL, session = shiny::getDefaultReactiveDomain() )
expr |
The code to run. This is usually a set of |
label |
The label for the history entry. |
hold_ms |
The time in milliseconds to keep the entry open. The
default is two times the |
session |
The Shiny session. The default is the current session. |
The block runs immediately. rewind writes the history entry after the
changes return from the browser.
The value of expr, invisibly.
if (interactive()) { library(shiny) server <- function(input, output, session) { rewind_enable() observeEvent(input$reset, { rewind_step(label = "Reset filters", { updateSelectInput(session, "region", selected = "All") updateSliderInput(session, "year", value = c(2000, 2026)) updateCheckboxInput(session, "only_active", value = FALSE) }) }) } }if (interactive()) { library(shiny) server <- function(input, output, session) { rewind_enable() observeEvent(input$reset, { rewind_step(label = "Reset filters", { updateSelectInput(session, "region", selected = "All") updateSliderInput(session, "year", value = c(2000, 2026)) updateCheckboxInput(session, "only_active", value = FALSE) }) }) } }
rewind captures inputs automatically. It cannot see the state that you
keep in a shiny::reactiveValues() object. Register that object here.
rewind then records the registered fields with the inputs, and writes
them back at an undo.
rewind_track( values, fields = NULL, id = NULL, session = shiny::getDefaultReactiveDomain() )rewind_track( values, fields = NULL, id = NULL, session = shiny::getDefaultReactiveDomain() )
values |
A |
fields |
Character vector of the field names to track. Use |
id |
A name for this group. |
session |
The Shiny session. The default is the current session. |
Register only the values that are true state. A derived value or a cached value does no harm, but it has no use. Do not register a value that an observer computes again immediately. The undo step then appears to do nothing.
TRUE, invisibly.
if (interactive()) { library(shiny) server <- function(input, output, session) { rewind_enable() state <- reactiveValues(selected = character(), zoom = 1) rewind_track(state, fields = c("selected", "zoom")) } }if (interactive()) { library(shiny) server <- function(input, output, session) { rewind_enable() state <- reactiveValues(selected = character(), zoom = 1) rewind_track(state, fields = c("selected", "zoom")) } }
This function draws the history as a vertical list of steps. The newest
step is at the end. The rail shows the current position. A click on a
step moves to that step. The rail gets its data from the server. You thus
do not have to write a render function.
rewind_ui(label = "History", max_height = "20rem", class = NULL)rewind_ui(label = "History", max_height = "20rem", class = NULL)
label |
The heading above the rail. Use |
max_height |
The CSS height at which the rail starts to scroll. |
class |
More CSS classes for the container element. |
rewind_ui() rewind_ui(label = "Steps", max_height = "12rem")rewind_ui() rewind_ui(label = "Steps", max_height = "12rem")
The keyboard shortcuts and rewind_buttons() are sufficient for most
applications. Use these functions to move through the history from your
own controls.
rewind_undo(session = shiny::getDefaultReactiveDomain()) rewind_redo(session = shiny::getDefaultReactiveDomain()) rewind_jump(index, session = shiny::getDefaultReactiveDomain()) rewind_clear(session = shiny::getDefaultReactiveDomain())rewind_undo(session = shiny::getDefaultReactiveDomain()) rewind_redo(session = shiny::getDefaultReactiveDomain()) rewind_jump(index, session = shiny::getDefaultReactiveDomain()) rewind_clear(session = shiny::getDefaultReactiveDomain())
session |
The Shiny session. The default is the current session. |
index |
The position to move to. The first position is 1. Position 1
holds the oldest entry that |
TRUE if the position changed. If not, FALSE. The functions
return the value invisibly.
if (interactive()) { library(shiny) server <- function(input, output, session) { rewind_enable() observeEvent(input$my_back_button, rewind_undo()) } }if (interactive()) { library(shiny) server <- function(input, output, session) { rewind_enable() observeEvent(input$my_back_button, rewind_undo()) } }
This function holds a block of code. The changes that the block makes do not become a step. They become part of the current state instead, so an undo never goes back across them. Use it for code that sets up a view: a saved view that you restore, a bookmark that you apply, or defaults that you load when the user logs in.
rewind_unrecorded( expr, hold_ms = NULL, session = shiny::getDefaultReactiveDomain() )rewind_unrecorded( expr, hold_ms = NULL, session = shiny::getDefaultReactiveDomain() )
expr |
The code to run. This is usually a set of |
hold_ms |
The time in milliseconds to keep the window open after
the block. The default is two times the |
session |
The Shiny session. The default is the current session. |
It is the other side of rewind_step(). That function makes the changes
of a block into one step with a label. This one makes them into no step
at all.
The value of expr, invisibly.
A pause and a resume in one observer have no effect. rewind captures
in its own observer, which runs after the one that made the change, so
capture has resumed by then. This function keeps a window open after
the block instead, so that the changes that come back from the browser
later are covered too.
A change from the user that still waits to be written becomes its own step first, so the block does not swallow it.
The steps in front of the position go, as they do for any change.
A change that the user makes inside the window is part of the current
state too. Keep hold_ms short.
if (interactive()) { library(shiny) server <- function(input, output, session) { rewind_enable() # A saved view. Loading it is not something to undo. observeEvent(input$load_view, { rewind_unrecorded({ updateSelectInput(session, "region", selected = "South") updateSliderInput(session, "year", value = c(2020, 2024)) }) }) } }if (interactive()) { library(shiny) server <- function(input, output, session) { rewind_enable() # A saved view. Loading it is not something to undo. observeEvent(input$load_view, { rewind_unrecorded({ updateSelectInput(session, "region", selected = "South") updateSliderInput(session, "year", value = c(2020, 2024)) }) }) } }