--- title: "Tutorial" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Tutorial} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", warning = FALSE, message = FALSE, eval = interactive() ) ``` ```{r libs} library(ggpaintr) library(ggplot2) library(rlang) # for `%||%`, sym(), call2(), abort() used in the examples ``` This tutorial walks through ggpaintr from the outside in. Section 1 gets you a running app from a one-line formula and introduces the built-in placeholders. Section 2 shows how to define your own placeholders, taking each argument of the three `ptr_define_placeholder_*()` constructors in turn. Section 3 leaves the turn-key entry point behind: you write your own Shiny app, embed several plots, and wire one control to all of them. Every app chunk is marked `eval = interactive()` — paste it at the R prompt to launch the app. Pure-ggplot chunks that produce a static plot run inline. # 1. Basics A ggpaintr **formula** is an ordinary `ggplot()` call written out as code, with **placeholder keywords** dropped in wherever a user should get to choose a value. `ptr_app()` reads the formula, turns each placeholder into a Shiny widget, and re-runs the `ggplot()` call with the user's inputs spliced back in. The smallest useful app is one line: ```{r app-basic} ptr_app( ggplot(mtcars, aes(x = ppVar, y = ppVar)) + geom_point() ) ``` This launches a Shiny app with two column dropdowns (one per `ppVar`), a layer panel, and an **Update plot** button. There is no `data =` argument: ggpaintr resolves the bare symbol `mtcars` in the calling environment (`envir`, default `parent.frame()`). The only required argument to `ptr_app()` is the formula itself; `envir`, `ui_text`, `css`, `expr_check`, and `spec` are all optional. ## The five built-in placeholders Each keyword maps to a fixed widget and a fixed way of folding the input back into the formula: | Keyword | Widget | Role | Folds back as | |------------|-----------------------------------|---------------|----------------------------------------| | `ppVar` | column picker (data-aware) | consumer | a column symbol, e.g. `mpg` | | `ppText` | text input | value | a string | | `ppNum` | numeric input | value | a number | | `ppExpr` | code box (validated) | value | live code, parsed to an expression | | `ppUpload` | file picker (+ dataset-name box) | source | a data frame | The three **roles** in the right column matter once you start defining your own placeholders (Section 2): - A **value** placeholder produces a self-contained value — it needs nothing from the rest of the formula. - A **consumer** placeholder needs the upstream data's column names (a `ppVar` dropdown can only list columns once it knows what data flows into it). - A **source** placeholder *produces* the data frame the rest of the formula reads. A formula can mix all of them, and placeholders work anywhere in a pipeline, not just inside `ggplot()`: ```{r formula-tour} ptr_app( mtcars |> dplyr::filter(ppExpr(mpg > 15)) |> ggplot(aes(x = ppVar, y = ppVar, color = ppVar)) + geom_point(size = ppNum) + labs(title = ppText) ) ``` ## Seeding the widgets Give a placeholder a single **positional argument** and it becomes the widget's starting value: ```{r app-basic-seeded} ptr_app( ggplot(mtcars, aes(x = ppVar("wt"), y = ppVar("mpg"))) + geom_point(size = ppNum(3), alpha = ppNum(0.6)) + labs(title = ppText("Weight vs. mileage")) ) ``` The app now boots with `wt`/`mpg` already picked, size 3, and the title pre-filled. The default is read *literally* from the formula text — it is never evaluated as user code. (`ppNum` does accept simple arithmetic like `ppNum(2 * pi)`, folded at build time against a small allowlist.) ## Nothing renders until you click Update ggpaintr re-draws only when the **Update plot** button is clicked. Changing a widget stages a new value but does not redraw on its own. This keeps a half-typed expression from strobing the plot — and it is the one thing to remember when scripting the app in tests: set the inputs, *then* click the button. # 2. Defining your own placeholders The five built-ins are themselves registered through the same public API you are about to use. You define a placeholder by calling one of three constructors, keyed by the role from Section 1: - `ptr_define_placeholder_value()` - `ptr_define_placeholder_consumer()` - `ptr_define_placeholder_source()` All three are thin wrappers over a shared core, so they take an overlapping set of arguments. We cover the **value** constructor in full first, then describe only what is *different* for consumer and source. A few facts hold for all three: - The registry is **process-global**. Calling a constructor *registers* the keyword as a side effect (and returns a plain-R function, see `embellish_eval` below); there is no `placeholders =` argument to thread anywhere. Register once near the top of your script, then use the keyword in any formula. - The keyword you register is the keyword you write in the formula. By convention built-ins use the `pp` prefix, but yours need not. ## 2.1 A value placeholder — every argument Here is a custom `ppPercent`: a 0–100 slider whose value is divided by 100 before it reaches the plot. It exercises **every** argument of `ptr_define_placeholder_value()`. ```{r define-value} ptr_define_placeholder_value( keyword = "ppPercent", build_ui = function(node, label = NULL, selected = NULL, named_args = list(), ...) { step <- named_args$step %||% 1 shiny::sliderInput( node$id, label = label %||% "Percent", min = 0, max = 100, value = selected %||% node$default %||% 50, step = step ) }, resolve_expr = function(value, node, ...) { if (is.null(value)) return(NULL) as.numeric(value) / 100 }, validate_session_input = function(value, ctx) { v <- suppressWarnings(as.numeric(value)) if (length(v) != 1L || is.na(v) || v < 0 || v > 100) { rlang::abort("Percent must be a single number between 0 and 100.") } value }, parse_positional_arg = ptr_arg_numeric(), parse_named_args = list(step = ptr_arg_numeric()), embellish_eval = function(x, ...) as.numeric(x) / 100, ui_text_defaults = list(label = "Percent for {param}") ) ``` Used in a formula — the positional `40` seeds the slider, the named `step = 5` is forwarded to `build_ui`: ```{r define-value-app} ptr_app( ggplot(mtcars, aes(x = ppVar("wt"), y = ppVar("mpg"))) + geom_point(alpha = ppPercent(40, step = 5)) ) ``` ### `keyword` The name to register and to write in formulas. Required. ### `build_ui` — and why its signature looks the way it does `build_ui` is a function that returns the Shiny control. Its **first argument is `node`** (required) — an object carrying, among other things, `node$id` (the input id you must give your widget) and `node$default` (the literal positional default from the formula, or `NULL`). The framework does not call `build_ui(node)` bare. It calls it through an internal injector that *also* passes: - `label` — the resolved label text (from `ui_text_defaults`, possibly overridden by the app's `ui_text`); - `selected` — `node$default`, injected **only when present and only if your function can receive it**; - `named_args` — the validated named arguments from the formula call. This is why the signature is `function(node, label = NULL, selected = NULL, named_args = list(), ...)`: - You **opt in** to an injected argument by naming it (`label`, `selected`, `named_args`) — or by having `...`. The injector checks your formals: if you neither name `selected` nor accept `...`, the default is never injected. Naming the ones you use and keeping `...` to swallow the rest is the safe, forward-compatible shape. - Seed your widget's starting value from `selected %||% node$default %||% `. `selected` carries the persisted input across re-renders; `node$default` is the boot seed; the fallback keeps the widget usable when the formula gave no default. - Use `node$id` — and *only* `node$id` — as the widget id. Do not namespace it yourself; the framework already did. ### `resolve_expr` `function(value, node, ...)` → the value (or expression) spliced back into the formula. `value` is the current input. Return `NULL` to contribute nothing (e.g. an empty field), which drops the argument cleanly. Here we divide by 100 so the plot sees a 0–1 alpha. ### `validate_session_input` Optional. `function(value, ctx)`, run before `resolve_expr`. Return the value to accept it, or `rlang::abort()` to surface an inline error. For mid-typing artifacts that should not flash an error, signal `ptr_signal_partial()` instead of aborting — it is caught on the live keystroke path but not on the draw path. (`ctx` carries context; for value placeholders it is mostly empty — it earns its keep for consumers, Section 2.2.) ### `parse_positional_arg` Declares whether the placeholder accepts a single positional default in the formula, and validates it. Pass one of the argument validators — `ptr_arg_string()`, `ptr_arg_numeric()`, `ptr_arg_symbol()`, `ptr_arg_symbol_or_string()`, `ptr_arg_expression()`. Each is a factory returning a checker that inspects the default **as unevaluated code** (no `eval()`), so `ppPercent(40)` is validated to be a numeric literal at translate time. The element factories also take `vector = TRUE` to accept a `c(...)` of elements instead of a scalar — `ptr_arg_numeric(vector = TRUE, length = 2)`, `ptr_arg_symbol(vector = TRUE)` (a multi-column default like `c(mpg, hp)`) — so a multi-column consumer can carry a positional default. Leaving `parse_positional_arg = NULL` (the default) **rejects** any positional argument — `ppPercent(40)` would error. ### `parse_named_args` A fully-named list mapping extra named-argument names to validators, in the same family as `parse_positional_arg`. It lets the formula write `ppPercent(40, step = 5)`. The validated values arrive in `build_ui` as the `named_args` list. The name `shared` is reserved (it is ggpaintr's cross-widget binding key — Section 3) and may not appear here. ### `embellish_eval` The plain-R meaning of the keyword *outside* `ptr_app()`. A placeholder-embellished formula must stay valid plain R that still renders the original plot with no app running; `embellish_eval` is the callable that supplies that meaning. Each constructor returns this function, so you can bind it under the keyword name — ```{r embellish-bind, eval = FALSE} ppPercent <- ptr_define_placeholder_value("ppPercent", ...) ppPercent(40) # => 0.4, as ordinary R ``` — which makes a formula that uses the keyword still evaluate as ordinary ggplot code. The meaning is **author-controlled, never derived** — only you know what the keyword should mean as live R. If you omit `embellish_eval`, value and consumer keywords default to `embellish_identity()` (the identity `function(x, ...) x`), so the placeholder call is a transparent no-op wrapper. Two built-in helpers cover the common cases: - `embellish_identity()` — the default; returns its argument unchanged. - `embellish_symbol_to_string()` — captures its argument *unevaluated* and returns the referenced column names as a character vector. This is the pattern a column-selecting consumer needs to run as plain R: a tidyselect verb evaluates an unknown wrapper call in non-masked scope, where bare column symbols throw `object 'mpg' not found`; returning the names as strings lets the naked formula still select by name. ### `ui_text_defaults` A named list of copy defaults over `label`, `help`, `placeholder`, and `empty_text`, with `{param}` interpolated to the argument name. These are the *defaults*; an app can override them per-keyword or per-parameter through `ptr_app(ui_text = ...)`. ## 2.2 A consumer placeholder — the delta A consumer is a value placeholder that additionally needs the **upstream column names**. Everything in 2.1 applies — `resolve_expr`, `validate_session_input`, `parse_positional_arg`, `parse_named_args`, `embellish_eval`, `ui_text_defaults` all mean the same thing. Only two things change. **`build_ui` gains two required arguments: `cols` and `data`.** The injector fills `cols` with the column names of the data flowing into this point of the pipeline, and `data` with that data frame, re-running `build_ui` whenever the upstream changes. Your picker's `choices` come from `cols`: ```{r define-consumer} ptr_define_placeholder_consumer( keyword = "colvars", build_ui = function(node, cols = character(), data = NULL, label = NULL, selected = character(0), ...) { shiny::selectInput( node$id, label = label %||% "Columns", choices = cols, selected = intersect(selected, cols), # keep only still-valid picks multiple = TRUE ) }, resolve_expr = function(value, node, ...) { if (length(value) == 0L) return(NULL) rlang::call2("c", !!!as.list(value)) # c(col1, col2, ...) }, parse_positional_arg = ptr_arg_symbol_or_string(), ui_text_defaults = list(label = "Columns for {param}") # validate_session_input / parse_named_args / embellish_eval: same shape as 2.1, omitted here. ) ``` ```{r define-consumer-app} ptr_app( mtcars |> dplyr::select(colvars) |> ggplot(aes(x = ppVar, y = ppVar)) + geom_point() ) ``` **`validate_session_input`'s `ctx` is now useful.** For a consumer, `ctx$data` holds the upstream data frame, so a validator can do data-aware checks — reject a non-numeric column, range-check the chosen values, and so on. (Same `function(value, ctx)` signature as 2.1; the difference is that `ctx$data` is populated.) ## 2.3 A source placeholder — the delta A source *produces* the data the rest of the formula reads, so it sits at the head of a pipeline. The shared arguments (`parse_positional_arg`, `parse_named_args`, `embellish_eval`, `ui_text_defaults`) work exactly as in 2.1. Three things differ. **`resolve_data` replaces `resolve_expr` as the required producer.** `function(value, node, ...)` must return a data frame. `resolve_expr` becomes *optional* and defaults to `function(value, node, ...) rlang::sym(value)` (the symbol that names the produced frame in generated code) — override it only if you need different generated code. **`embellish_eval` defaults to an abort guard**, not identity — a source has no sensible plain-R meaning until you give it one. Override `embellish_eval` if you want the formula to be runnable as ordinary R. **`shortcut = TRUE` adds a framework-owned companion text box.** When set, ggpaintr renders a sibling `textInput` (at `node$shortcut_id`) into which the user can type the *name* of an object to load from the app environment — the same "or type a dataset name" box you saw on `ppUpload`. Because the framework owns that box, **your `build_ui` must render only `node$id`** (or, for an env-name-only source, nothing at all) — rendering the shortcut yourself would bind the id twice. This `ppDataset` lets the user type the name of any data frame in scope; the slider/selector style of widget is unnecessary because the framework text box is the entry point. Note that `resolve_data` runs later, inside the framework — so we capture the registration environment now (`.env`) and load from it, rather than reaching for `parent.frame()` at resolve time: ```{r define-source} .env <- environment() # the scope whose data frames should be loadable ptr_define_placeholder_source( keyword = "ppDataset", shortcut = TRUE, build_ui = function(node, label = NULL, ...) { # env-name-only source: the framework's shortcut text box is the sole # entry point, so build_ui contributes no widget of its own. NULL }, resolve_data = function(value, node, ...) { nm <- if (is.character(value) && length(value) == 1L && nzchar(value)) value else NULL if (is.null(nm)) return(NULL) tryCatch(get(nm, envir = .env, inherits = TRUE), error = function(e) NULL) }, resolve_expr = function(value, node, ...) rlang::sym(value), ui_text_defaults = list(label = "Dataset for {param}") ) ``` ```{r define-source-app} ptr_app( ppDataset() |> ggplot(aes(x = ppVar("mpg"))) + geom_histogram() ) ``` A source that owns a real widget (e.g. a `selectInput` of dataset names) is the same shape with `shortcut = FALSE` and a `build_ui` that renders the picker at `node$id`. # 3. Multiple plots, and writing your own Shiny app `ptr_app()` is the turn-key entry point: it builds the whole Shiny app for you. When you want to place a ggpaintr plot inside your *own* app — alongside other UI, several plots at once, controls of your own — you drop down one level to `ptr_ui()` / `ptr_server()` and own the `shinyApp()` shell yourself. ## One plot inside your own app You write the `fluidPage` and the server function. Put `ptr_ui(formula, id)` where the plot's controls and output should go, and `ptr_server(formula, id)` in the server with a **matching `id`**. `ptr_server()` namespaces itself — call it **bare**, never wrapped in your own `moduleServer()`: `ptr_ui()` and `ptr_server()` take the formula the same way `ptr_app()` does: pass the `ggplot()` call directly, or — to write it once and hand the *same* formula to both — store it with `rlang::expr()` and splice it in with `!!`. (The string form still works as a fallback.) ```{r module-app} f <- rlang::expr(ggplot(mtcars, aes(x = ppVar("wt"), y = ppVar("mpg"))) + geom_point()) ui <- shiny::fluidPage( shiny::h3("My dashboard"), ptr_ui(!!f, "plot1") ) server <- function(input, output, session) { ptr_server(!!f, "plot1") } shiny::shinyApp(ui, server) ``` The `id` (`"plot1"`) is the namespace shared by the UI and the server; they must agree on it. Omitting it (`id = NULL`) gives bare, un-namespaced ids — fine for a single plot. ## Sharing one control across several plots Often several plots should be driven by the *same* control — one x-axis picker, one size slider — rather than a copy per plot. You declare this with the reserved `shared = ""` argument on any placeholder. Placeholders that carry the same key are backed by a single widget. How that single widget is *placed* depends on how many formulas reference the key — this is the **partition**: - A key used by **two or more formulas** is owned by a standalone **shared panel** that sits above the plots and drives all of them. - A key used by **exactly one formula** renders **inline** in that one plot's own controls. For several plots you build a small coordinator object with `ptr_shared()` and hand it to three pieces: `ptr_shared_panel()` (the standalone panel UI), each `ptr_ui(..., shared = obj)`, and `ptr_shared_server()` (whose result you thread into each `ptr_server(..., shared_state = )`). The example below has two scatter plots over `iris`. The **size** slider is shared by both formulas, so it lands in the standalone panel and moves both plots at once. Each plot's **x-axis** picker is shared within its own formula only, so it renders inline under that plot: ```{r l2-shared-partition} # A custom value placeholder for the shared size control: a 1-6 slider. ptr_define_placeholder_value( keyword = "ppSize", parse_positional_arg = ptr_arg_numeric(), build_ui = function(node, label = NULL, selected = NULL, ...) { val <- suppressWarnings(as.numeric(selected %||% node$default %||% 3)) if (length(val) != 1L || is.na(val)) val <- 3 shiny::sliderInput(node$id, label %||% "Size", min = 1, max = 6, value = val) }, resolve_expr = function(value, node, ...) { out <- suppressWarnings(as.numeric(value)) if (length(out) != 1L || is.na(out)) NULL else out } ) plots <- list( rlang::expr(ggplot(iris, aes(x = ppVar(shared = "ax1"), y = Sepal.Width, color = Species)) + geom_point(size = ppSize(shared = "sz"))), rlang::expr(ggplot(iris, aes(x = ppVar(shared = "ax2"), y = Petal.Width, color = Species)) + geom_point(size = ppSize(shared = "sz"))) ) obj <- ptr_shared(formulas = plots) # a list of formulas, passed as-is obj$panel_keys # "sz" -- used by both formulas, so panel-owned ui <- shiny::fluidPage( ptr_shared_panel(obj), # holds the shared size slider shiny::fluidRow( shiny::column(6, ptr_ui(!!plots[[1]], "plot_1", shared = obj)), # ax1 inline shiny::column(6, ptr_ui(!!plots[[2]], "plot_2", shared = obj)) # ax2 inline ) ) server <- function(input, output, session) { sh <- ptr_shared_server(obj) ptr_server(!!plots[[1]], "plot_1", shared_state = sh) ptr_server(!!plots[[2]], "plot_2", shared_state = sh) } shiny::shinyApp(ui, server) ``` `obj$panel_keys` reports which keys ended up panel-owned — here, `"sz"`. Move the size slider in the panel and click the panel's draw button: both plots re-render in lockstep. Each x-axis picker, being formula-local, changes only its own plot. The same partition rule means that with a **single** ggpaintr instance, reusing one `shared` key several times in one formula needs no coordinator at all — ggpaintr renders one inline widget and wires every occurrence to it automatically. `ptr_shared()` and the panel are only for the multi-formula case. This is where the tutorial stops. For tailoring labels and copy, theming, and the trust model behind `ppExpr` and `ppUpload`, see the companion vignettes.