Skip to contents

Overview

ggpaintr now supports per-app placeholder registries. That means downstream developers can define a new placeholder once, pass it into ptr_parse_formula(), ptr_app(), or ptr_server_state(), and keep the rest of the package runtime unchanged.

The public entry points for this layer are:

Built-in placeholders such as var, text, num, expr, and upload now use the same registry path as custom placeholders.

A minimal custom placeholder

This date placeholder adds a Shiny dateInput() control and turns the chosen date into as.Date("YYYY-MM-DD") in generated code.

sales <- data.frame(
  day = as.Date("2024-01-01") + 0:4,
  value = c(10, 13, 12, 16, 18)
)

date_placeholder <- ptr_define_placeholder(
  # keyword: the token written inside the formula string (e.g. `date` in
  # `geom_vline(xintercept = date)`). Must be a valid R name.
  keyword = "date",

  # build_ui: produces the Shiny widget for one placeholder occurrence.
  # Arguments:
  #   id      — the Shiny input id generated by ggpaintr; pass it to the widget
  #             so the reactive system can read it with input[[id]].
  #   copy    — resolved label/help text for this occurrence. A named list with
  #             fields: $label, $help, $placeholder, $empty_text.
  #             Values come from copy_defaults (defined below), and can be
  #             overridden per-param or per-layer via ptr_merge_ui_text().
  #   meta    — metadata record for this specific placeholder occurrence:
  #               meta$id          same as `id` above
  #               meta$keyword     "date"
  #               meta$layer_name  the ggplot layer (e.g. "geom_vline")
  #               meta$param       the argument name (e.g. "xintercept")
  #             Use meta$param or meta$layer_name to vary widget behaviour by
  #             context (e.g. different defaults per argument).
  #   context — shared read-only environment:
  #               context$ptr_obj       the full parsed formula object
  #               context$placeholders  the active placeholder registry
  #               context$ui_text       merged copy rules
  #               context$envir         calling environment (for resolving data)
  build_ui = function(id, copy, meta, context) {
    shiny::dateInput(id, copy$label)  # copy$label is filled from copy_defaults below
  },

  # resolve_expr: converts the current widget value into an R expression that
  # replaces the `date` token in the completed formula.
  # Arguments:
  #   value   — the current input value (result of input[[id]], or of
  #             resolve_input() if that hook is defined)
  #   meta    — same metadata record as in build_ui
  #   context — same shared environment as in build_ui
  resolve_expr = function(value, meta, context) {
    # ptr_missing_expr() signals that this argument should be dropped from the
    # completed formula entirely (rather than inserting a NULL or NA).
    if (is.null(value) || identical(as.character(value), "")) {
      return(ptr_missing_expr())
    }

    # !! (unquote) splices the runtime value into the expression at build time.
    # The result here is the call `as.Date("2024-01-03")` embedded in the plot code.
    rlang::expr(as.Date(!!as.character(value)))
  },

  # copy_defaults: default label/help text shown for every occurrence of this
  # placeholder. {param} is a glue-style template filled with meta$param at
  # runtime (e.g. "xintercept" → "Choose a date for xintercept").
  # Override specific occurrences with ptr_merge_ui_text().
  copy_defaults = list(label = "Choose a date for {param}")
)

# ptr_merge_placeholders() merges custom placeholders into the built-in registry
# (var, text, num, expr, upload). The result is a complete registry that can be
# passed as `placeholders =` to ptr_parse_formula(), ptr_app(), ptr_exec(), etc.
# Custom entries override built-ins of the same keyword name.
placeholders <- ptr_merge_placeholders(
  list(date = date_placeholder)
)

names(placeholders)  # built-ins + "date"
#> [1] "var"    "text"   "num"    "expr"   "upload" "date"

Using the registry in parsing and runtime

Pass the registry through placeholders = ... anywhere you want custom placeholder support.

obj <- ptr_parse_formula(
  "ggplot(data = sales, aes(x = day, y = value)) +
    geom_line() +
    geom_vline(xintercept = date)",
  placeholders = placeholders
)

# Input ids follow internal conventions — prefer discovering them via
# ptr_runtime_input_spec(obj) rather than hard-coding strings.
# The patterns shown here are for illustration only:
#   "<layer>+checkbox"  — toggle that layer on (TRUE) or off (FALSE)
#   "<layer>+<n>"       — the n-th placeholder in that layer (1-based)
runtime <- ptr_exec(
  obj,
  list(
    "geom_line+checkbox"  = TRUE,              # include geom_line in the plot
    "geom_vline+checkbox" = TRUE,              # include geom_vline in the plot
    "geom_vline+2"        = as.Date("2024-01-03")  # value for the date placeholder
  )
)

runtime$code_text
#> [1] "ggplot(data = sales, aes(x = day, y = value)) +\n  geom_line() +\n  geom_vline(xintercept = as.Date(\"2024-01-03\"))"

The same registry can also be used in the higher-level wrappers.

ptr_app(
  "ggplot(data = sales, aes(x = day, y = value)) +
    geom_line() +
    geom_vline(xintercept = date)",
  placeholders = placeholders
)

The placeholder metadata contract

Every placeholder occurrence becomes one metadata record in ptr_obj$placeholder_map.

meta <- obj$placeholder_map$geom_vline[["geom_vline+2"]]
meta
#> $id
#> [1] "geom_vline+2"
#> 
#> $keyword
#> [1] "date"
#> 
#> $layer_name
#> [1] "geom_vline"
#> 
#> $param
#> [1] "xintercept"
#> 
#> $index_path
#> [1] 2

Each metadata record contains:

  • id: the Shiny input id for that placeholder occurrence
  • keyword: the placeholder keyword, such as date
  • layer_name: the layer where the placeholder was found
  • param: the argument name, such as xintercept
  • index_path: the expression path used during replacement

These fields are passed into every placeholder hook so custom placeholder code can behave differently by parameter or by layer.

The hook contract

ptr_define_placeholder() supports one required UI hook, one required resolver hook, and three optional advanced hooks.

build_ui(id, copy, meta, context)

build_ui() returns the UI control for one placeholder occurrence.

date_placeholder$build_ui
#> function (id, copy, meta, context) 
#> {
#>     shiny::dateInput(id, copy$label)
#> }

The arguments are:

  • id: the generated input id to bind
  • copy: resolved copy text for this control
  • meta: the metadata record for this occurrence
  • context: a named list containing ptr_obj, placeholders, ui_text, and envir

resolve_expr(value, meta, context)

resolve_expr() turns the current input value into an expression suitable for the completed plot expression.

date_placeholder$resolve_expr
#> function (value, meta, context) 
#> {
#>     if (is.null(value) || identical(as.character(value), "")) {
#>         return(ptr_missing_expr())
#>     }
#>     rlang::expr(as.Date(!!as.character(value)))
#> }

Return ptr_missing_expr() when the argument should be removed from the completed code instead of inserted.

resolve_input(input, id, meta, context)

Use resolve_input() when the raw Shiny input needs preprocessing before it is handed to resolve_expr(). If omitted, ggpaintr uses input[[id]].

trimmed_text_placeholder <- ptr_define_placeholder(
  keyword = "trimmed_text",
  build_ui = function(id, copy, meta, context) {
    shiny::textInput(id, copy$label)
  },
  # resolve_input: pre-processes the raw Shiny input value before it reaches
  # resolve_expr(). If omitted, ggpaintr uses input[[id]] directly.
  # Arguments:
  #   input — the full Shiny input object (access any input, not just this one)
  #   id    — the input id for this placeholder occurrence
  #   meta, context — same as in build_ui and resolve_expr
  # Return the cleaned value; it becomes `value` in resolve_expr().
  resolve_input = function(input, id, meta, context) {
    trimws(input[[id]])  # strip leading/trailing whitespace before resolve_expr sees it
  },
  resolve_expr = function(value, meta, context) {
    if (identical(value, "")) {
      return(ptr_missing_expr())
    }

    rlang::expr(!!value)
  }
)

bind_ui(input, output, metas, context)

Use bind_ui() for deferred or dynamic UI registration. This is how the built-in var placeholder registers delayed renderUI() outputs after data is known.

deferred_placeholder <- ptr_define_placeholder(
  keyword = "dynamic_text",
  build_ui = function(id, copy, meta, context) {
    # Return a placeholder container. The real widget is rendered later by bind_ui()
    # once the data or other runtime context needed to build it is available.
    shiny::uiOutput(paste0("dynamic-", id))
  },
  # bind_ui: called during ptr_setup_controls() inside the Shiny server.
  # Use it to register renderUI() observers for widgets that depend on reactive
  # state (e.g. column names from an uploaded file).
  # Unlike build_ui() — which runs once at UI construction time and has no
  # access to reactives — bind_ui() runs inside the server and can read inputs.
  # Arguments:
  #   input, output — standard Shiny server arguments
  #   metas   — list of metadata records, one per occurrence of this keyword;
  #             each record has $id, $keyword, $layer_name, $param, $index_path
  #   context — shared environment (ptr_obj, placeholders, ui_text, envir)
  bind_ui = function(input, output, metas, context) {
    for (meta in metas) {
      # Register a renderUI() for the uiOutput() container built above.
      output[[paste0("dynamic-", meta$id)]] <- shiny::renderUI({
        shiny::textInput(meta$id, paste("Dynamic control for", meta$param))
      })
    }

    invisible(NULL)
  },
  resolve_expr = function(value, meta, context) {
    if (is.null(value) || identical(value, "")) {
      return(ptr_missing_expr())
    }

    rlang::expr(!!value)
  }
)

prepare_eval_env(input, metas, eval_env, context)

Use prepare_eval_env() when a placeholder needs to inject objects into the evaluation environment before the plot is built. This is how the built-in upload placeholder assigns uploaded data objects before code is evaluated.

prepared_data_placeholder <- ptr_define_placeholder(
  keyword = "prepared_data",
  build_ui = function(id, copy, meta, context) {
    shiny::textInput(id, copy$label, placeholder = "dataset_name")
  },
  resolve_expr = function(value, meta, context) {
    if (is.null(value) || identical(value, "")) {
      return(ptr_missing_expr())
    }

    rlang::parse_expr(value)
  },
  # prepare_eval_env: runs just before the completed formula is evaluated to
  # build the plot. Use it to inject R objects that the formula references by
  # name (e.g. a dataset variable, a helper function, a colour palette).
  # This is how the built-in `upload` placeholder makes uploaded data available
  # under the user-supplied name at eval time.
  # Arguments:
  #   input    — Shiny input object (read current widget values if needed)
  #   metas    — list of metadata records for all occurrences of this keyword
  #   eval_env — the environment in which the formula will be eval()'d;
  #              add or overwrite bindings, then return the modified env
  #   context  — shared environment (ptr_obj, placeholders, ui_text, envir)
  prepare_eval_env = function(input, metas, eval_env, context) {
    # Bind `prepared_dataset` so the formula can reference it by name.
    eval_env$prepared_dataset <- data.frame(x = 1:3, y = c(2, 4, 3))
    eval_env  # must return the (modified) eval_env
  }
)

Copy defaults and custom copy rules

Each placeholder can ship its own default copy through copy_defaults. Those defaults then participate in the normal ui_text system.

# ptr_merge_ui_text() builds a copy-rules object that overrides default labels
# and help text. Three levels of specificity — higher wins over lower:
#
#   defaults  → applies to every occurrence of a keyword, across all layers/params
#   params    → applies to a specific argument name, across all layers
#   layers    → applies to a keyword inside a specific layer + argument combination
#
# Within each level you can set: label, help, placeholder, empty_text.
date_ui_text <- ptr_merge_ui_text(
  list(
    # Fallback for any `date` placeholder not matched by a more specific rule.
    defaults = list(date = list(label = "Pick any date")),

    # Overrides the label when the argument is named `xintercept`, regardless of layer.
    params = list(
      xintercept = list(date = list(label = "Reference date"))
    ),

    # Most specific: overrides copy only for `date` inside `geom_vline(xintercept = ...)`.
    # This wins over both `defaults` and `params` for that exact occurrence.
    layers = list(
      geom_vline = list(
        date = list(
          xintercept = list(help = "Choose a cutoff date for the vertical guide.")
        )
      )
    )
  ),
  placeholders = placeholders
)

# ptr_resolve_ui_text() returns the fully merged copy record for one occurrence.
# Precedence applied: layers > params > defaults > copy_defaults from the placeholder.
# The result is what build_ui() receives as its `copy` argument.
ptr_resolve_ui_text(
  "control",          # component type: "control" for placeholder widgets
  keyword    = "date",
  layer_name = "geom_vline",
  param      = "xintercept",
  ui_text    = date_ui_text,
  placeholders = placeholders
)
#> $label
#> [1] "Reference date"
#> 
#> $help
#> [1] "Choose a cutoff date for the vertical guide."

Using custom placeholders with Shiny integration helpers

The phase-1 Shiny integration helpers work with custom placeholder registries too.

ids <- ptr_build_ids(
  control_panel = "builder_controls",
  draw_button = "render_plot",
  plot_output = "main_plot",
  error_output = "main_error",
  code_output = "main_code"
)

ui <- shiny::fluidPage(
  shiny::sidebarLayout(
    shiny::sidebarPanel(
      ptr_input_ui(ids = ids)
    ),
    shiny::mainPanel(
      ptr_output_ui(ids = ids)
    )
  )
)

server <- function(input, output, session) {
  ptr_state <- ptr_server_state(
    "ggplot(data = sales, aes(x = day, y = value)) +
      geom_line() +
      geom_vline(xintercept = date)",
    ids = ids,
    placeholders = placeholders
  )

  ptr_setup_controls(input, output, ptr_state, ids = ids)
  ptr_register_draw(input, ptr_state, ids = ids)
  ptr_register_plot(output, ptr_state, ids = ids)
  ptr_register_error(output, ptr_state, ids = ids)
  ptr_register_code(output, ptr_state, ids = ids)
}

Summary

The placeholder registry is the developer-facing extension point for adding new control types without editing parser, UI, runtime, or copy-rule internals. Start with build_ui() plus resolve_expr(), add the optional hooks only when needed, and pass the same registry through parsing and app launch.