From 79a5935373bcfc24f3e91a2fdff2664aa40846fa Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Thu, 20 Aug 2026 18:58:48 -0700 Subject: [PATCH 01/18] Close with recordPlot --- NAMESPACE | 1 + R/tinyplot.R | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/NAMESPACE b/NAMESPACE index 90bf6e3d4..fa10f8b36 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -81,6 +81,7 @@ importFrom(grDevices, pdf, png, recordGraphics, + recordPlot, svg, xy.coords ) diff --git a/R/tinyplot.R b/R/tinyplot.R index 36c26c435..f813a73bc 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -470,7 +470,7 @@ #' out existing `plot` calls for `tinyplot` (or its shorthand alias `plt`), #' without causing unexpected changes to the output. #' -#' @importFrom grDevices axisTicks adjustcolor cairo_pdf chull colorRampPalette dev.cur dev.list dev.off dev.new extendrange hcl.colors hcl.pals jpeg palette palette.colors palette.pals pdf png svg xy.coords +#' @importFrom grDevices axisTicks adjustcolor cairo_pdf chull colorRampPalette dev.cur dev.list dev.off dev.new extendrange hcl.colors hcl.pals jpeg palette palette.colors palette.pals pdf png recordPlot svg xy.coords #' @importFrom graphics abline arrows axis Axis axTicks box boxplot grconvertX grconvertY hist lines mtext par plot.default plot.new plot.window points polygon polypath segments rect text title #' @importFrom utils modifyList head tail #' @importFrom stats na.omit setNames @@ -1759,6 +1759,8 @@ tinyplot.default = function( env = getNamespace('tinyplot') ) } + + invisible(recordPlot()) } From dbace2046afcc3dde43c422db1d018eb00326cf4 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Thu, 10 Sep 2026 10:50:15 -0700 Subject: [PATCH 02/18] rather make optional - top-level "record" argument and equivalent tpar param for setting globally --- R/tinyplot.R | 36 +++++++++++++++++++++++++++++++++--- R/tpar.R | 3 +++ R/zzz.R | 1 + man/tinyplot.Rd | 12 +++++++++++- man/tpar.Rd | 1 + 5 files changed, 49 insertions(+), 4 deletions(-) diff --git a/R/tinyplot.R b/R/tinyplot.R index b55bba03e..bf348809f 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -467,6 +467,11 @@ #' \code{\link[tinyplot]{tpar}} (i.e., both defaulting to 7 inches, and where #' the default resolution for bitmap files is also specified as 300 #' DPI). +#' @param record logical. Should the plot be recorded and returned as a +#' replayable object (see \code{\link[grDevices]{recordPlot}})? Defaults to +#' `FALSE`. Setting to `TRUE` allows for assignment and later recall, e.g. +#' `myplot = tinyplot(...); myplot`. This behaviour can also be set globally +#' via `tpar(record = TRUE)`; an explicit argument here takes precedence. #' @param width numeric giving the plot width in inches. Together with `height`, #' typically used in conjunction with the `file` argument above, overriding the #' default values held in `tpar("file.width", "file.height")`. If either `width` @@ -488,7 +493,10 @@ #' All remaining arguments from `...` can be further graphical parameters, see #' \code{\link[graphics]{par}}). #' -#' @returns No return value, called for side effect of producing a plot. +#' @returns By default, no return value; called for the side effect of producing +#' a plot. If `record = TRUE` (or globally via `tpar(record = TRUE)`), the +#' plot is instead returned invisibly as a `"recordedplot"` object, which can +#' be replayed later; see \code{\link[grDevices]{recordPlot}}. #' #' @details #' Disregarding the enhancements that it supports, `tinyplot` tries as far as @@ -497,7 +505,7 @@ #' out existing `plot` calls for `tinyplot` (or its shorthand alias `plt`), #' without causing unexpected changes to the output. #' -#' @importFrom grDevices axisTicks adjustcolor cairo_pdf chull colorRampPalette dev.cur dev.list dev.off dev.new extendrange hcl.colors hcl.pals jpeg palette palette.colors palette.pals pdf png recordPlot svg xy.coords +#' @importFrom grDevices axisTicks adjustcolor cairo_pdf chull colorRampPalette dev.control dev.cur dev.list dev.off dev.new extendrange hcl.colors hcl.pals jpeg palette palette.colors palette.pals pdf png recordPlot svg xy.coords #' @importFrom graphics abline arrows axis Axis axTicks box boxplot grconvertX grconvertY hist lines mtext par plot.default plot.new plot.window points polygon polypath segments rect text title #' @importFrom utils modifyList head tail #' @importFrom stats na.omit setNames var @@ -800,6 +808,7 @@ tinyplot.default = function( file = NULL, width = NULL, height = NULL, + record = NULL, asp = NA, theme = NULL, ...) { @@ -816,6 +825,10 @@ tinyplot.default = function( par_first = get_saved_par("first") if (is.null(par_first)) set_saved_par("first", par()) + # Resolve `record`: an explicit argument wins over the tpar default. + if (is.null(record)) record = get_tpar("record", default = FALSE) + assert_flag(record, name = "record") + # Validate grid only for simple values; skip for unevaluated calls like grid() # which are passed as language objects from tinyplot.formula via substitute(). (#193) if (!is.null(grid) && !is.call(grid)) { @@ -914,6 +927,7 @@ tinyplot.default = function( file = file, width = width, height = height, + record = record, # deparsed input for use in labels by_dep = deparse1(substitute(by)), @@ -1911,7 +1925,23 @@ tinyplot.default = function( ) } - invisible(recordPlot()) + if (isTRUE(record)) { + rec = recordPlot() + # A device that is not recording still yields a well-formed "recordedplot", + # just an empty one that replays blank. Say so rather than handing back + # something that silently does nothing. + if (length(rec[[1]]) == 0L) { + warning( + "`record = TRUE` but the current device is not recording, so the ", + "returned plot is empty and will replay blank. Call ", + "dev.control(displaylist = \"enable\") on the device first.", + call. = FALSE + ) + } + return(invisible(rec)) + } + + return(invisible(NULL)) } diff --git a/R/tpar.R b/R/tpar.R index e6e1e074d..f471035a3 100644 --- a/R/tpar.R +++ b/R/tpar.R @@ -87,6 +87,7 @@ #' * `lwd.xaxs`, `lwd.yaxs`: Line widths for the x- and y-axis lines, respectively. Both default to `NULL`, whereby the shared `lwd.axis` value is used instead. #' * `palette.qualitative`: Palette for qualitative colors. See the `palette` argument in `?tinyplot`. #' * `palette.sequential`: Palette for sequential colors. See the `palette` argument in `?tinyplot`. +#' * `record`: Logical indicating whether `tinyplot()` should record plots and return them as replayable objects (see \code{\link[grDevices]{recordPlot}}). Defaults to `NULL`, which is equivalent to `FALSE`. Setting to `TRUE` allows for assignment and later recall, e.g. `myplot = tinyplot(...); myplot`. Sets the default for the `record` argument of [`tinyplot()`], which takes precedence. #' * `ribbon.alpha`: Numeric factor in the range `[0,1]` for modifying the opacity alpha of "ribbon" and "area" type plots. Default value is `0.2`. #' * `xaxr`, `yaxr`: Numeric giving the rotation of the x- and y-axis tick labels, in degrees counter-clockwise; `NULL` (the default) leaves them unrotated. Unlike `las`, which is limited to the four right angles, any angle is permitted. Setting one overrides `las` for that axis alone, leaving the other axis under `las` as usual, and `0` (or any multiple of 360) counts as no rotation at all. Sets the default for the `xaxr` and `yaxr` arguments of [`tinyplot()`], which take precedence. Two caveats follow from tinyplot drawing rotated labels itself rather than deferring to base `axis()`. First, margins are only resized to fit them under a theme with `dynmar = TRUE` (see `tinytheme`); under the default theme the margins are left alone, so a long rotated label will be clipped unless you widen `mar` yourself. Second, rotated labels do not inherit the thinning that `axis()` applies via `gap.axis`, so they start to overlap once the spacing between ticks falls below `line height / sin(srt)`. #' @@ -313,6 +314,7 @@ known_tpar = c( "pch", "palette.qualitative", "palette.sequential", + "record", "ribbon.alpha", "side.sub", "tinytheme", @@ -344,6 +346,7 @@ assert_tpar = function(.tpar) { assert_flag(.tpar[["dynmar"]], null.ok = FALSE, name = "dynmar") assert_choice(.tpar[["ljust"]], choice = c("left", "center", "l", "c"), null.ok = TRUE, name = "ljust") assert_numeric(.tpar[["lmar"]], len = 2, null.ok = TRUE, name = "lmar") + assert_flag(.tpar[["record"]], null.ok = TRUE, name = "record") assert_numeric(.tpar[["ribbon.alpha"]], len = 1, lower = 0, upper = 1, null.ok = TRUE, name = "ribbon.alpha") assert_numeric(.tpar[["grid.lwd"]], len = 1, lower = 0, null.ok = TRUE, name = "grid.lwd") assert_grid(.tpar[["grid"]], null.ok = TRUE, name = "grid") diff --git a/R/zzz.R b/R/zzz.R index 9a69f29c1..92244d07f 100644 --- a/R/zzz.R +++ b/R/zzz.R @@ -78,6 +78,7 @@ "oxaxis", "oyaxis", "pch", + "record", "rev_x", "rev_y", "ribbon.alpha", diff --git a/man/tinyplot.Rd b/man/tinyplot.Rd index 59521b8ac..fdfa8386e 100644 --- a/man/tinyplot.Rd +++ b/man/tinyplot.Rd @@ -64,6 +64,7 @@ tinyplot(x, ...) file = NULL, width = NULL, height = NULL, + record = NULL, asp = NA, theme = NULL, ... @@ -651,6 +652,12 @@ graphics windows.} \code{width} (above) apply, e.g. will default to \code{tpar("file.height")} if not specified.} +\item{record}{logical. Should the plot be recorded and returned as a +replayable object (see \code{\link[grDevices]{recordPlot}})? Defaults to +\code{FALSE}. Setting to \code{TRUE} allows for assignment and later recall, e.g. +\verb{myplot = tinyplot(...); myplot}. This behaviour can also be set globally +via \code{tpar(record = TRUE)}; an explicit argument here takes precedence.} + \item{asp}{the y/xy/x aspect ratio, see \code{plot.window}.} \item{theme}{keyword string (e.g. \code{"clean"}) or list defining a theme. Passed @@ -672,7 +679,10 @@ should not be specified in the same call.} when extracting the data from \code{formula} and \code{data}.} } \value{ -No return value, called for side effect of producing a plot. +By default, no return value; called for the side effect of producing +a plot. If \code{record = TRUE} (or globally via \code{tpar(record = TRUE)}), the +plot is instead returned invisibly as a \code{"recordedplot"} object, which can +be replayed later; see \code{\link[grDevices]{recordPlot}}. } \description{ Enhances the base \code{\link[graphics]{plot}} function. Supported features diff --git a/man/tpar.Rd b/man/tpar.Rd index 7523478c6..584395e13 100644 --- a/man/tpar.Rd +++ b/man/tpar.Rd @@ -98,6 +98,7 @@ you should rather use \code{par()} instead. \item \code{lwd.xaxs}, \code{lwd.yaxs}: Line widths for the x- and y-axis lines, respectively. Both default to \code{NULL}, whereby the shared \code{lwd.axis} value is used instead. \item \code{palette.qualitative}: Palette for qualitative colors. See the \code{palette} argument in \code{?tinyplot}. \item \code{palette.sequential}: Palette for sequential colors. See the \code{palette} argument in \code{?tinyplot}. +\item \code{record}: Logical indicating whether \code{tinyplot()} should record plots and return them as replayable objects (see \code{\link[grDevices]{recordPlot}}). Defaults to \code{NULL}, which is equivalent to \code{FALSE}. Setting to \code{TRUE} allows for assignment and later recall, e.g. \verb{myplot = tinyplot(...); myplot}. Sets the default for the \code{record} argument of \code{\link[=tinyplot]{tinyplot()}}, which takes precedence. \item \code{ribbon.alpha}: Numeric factor in the range \verb{[0,1]} for modifying the opacity alpha of "ribbon" and "area" type plots. Default value is \code{0.2}. \item \code{xaxr}, \code{yaxr}: Numeric giving the rotation of the x- and y-axis tick labels, in degrees counter-clockwise; \code{NULL} (the default) leaves them unrotated. Unlike \code{las}, which is limited to the four right angles, any angle is permitted. Setting one overrides \code{las} for that axis alone, leaving the other axis under \code{las} as usual, and \code{0} (or any multiple of 360) counts as no rotation at all. Sets the default for the \code{xaxr} and \code{yaxr} arguments of \code{\link[=tinyplot]{tinyplot()}}, which take precedence. Two caveats follow from tinyplot drawing rotated labels itself rather than deferring to base \code{axis()}. First, margins are only resized to fit them under a theme with \code{dynmar = TRUE} (see \code{tinytheme}); under the default theme the margins are left alone, so a long rotated label will be clipped unless you widen \code{mar} yourself. Second, rotated labels do not inherit the thinning that \code{axis()} applies via \code{gap.axis}, so they start to overlap once the spacing between ticks falls below \verb{line height / sin(srt)}. } From 31bd358a780aa0f76b0324a78c431d96a56149dd Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Thu, 10 Sep 2026 11:40:27 -0700 Subject: [PATCH 03/18] displaylist gotcha for file-based devices --- R/setup_device.R | 6 +++++- R/tinyplot.R | 6 ++++++ R/tpar.R | 2 +- man/tinyplot.Rd | 8 +++++++- man/tpar.Rd | 2 +- 5 files changed, 20 insertions(+), 4 deletions(-) diff --git a/R/setup_device.R b/R/setup_device.R index 76441fca5..ce3a0f250 100644 --- a/R/setup_device.R +++ b/R/setup_device.R @@ -1,5 +1,5 @@ setup_device = function(settings) { - env2env(settings, environment(), c("file", "width", "height")) + env2env(settings, environment(), c("file", "width", "height", "record")) # write to file if (!is.null(file)) { @@ -29,6 +29,10 @@ setup_device = function(settings) { svg = svg(filepath, width = filewidth, height = fileheight), stop("\nUnsupported file extension. Only '.png', '.jpg', '.pdf', or '.svg' are allowed.\n") ) + # File devices default to displaylist = "inhibit", which would make + # recordPlot() return an empty plot. This device is opened and closed by + # tinyplot, so enabling it here has no effect on the user's own devices. + if (isTRUE(record)) dev.control(displaylist = "enable") dop$new = FALSE # catch for some interfaces par(dop) diff --git a/R/tinyplot.R b/R/tinyplot.R index bf348809f..089b2740e 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -472,6 +472,12 @@ #' `FALSE`. Setting to `TRUE` allows for assignment and later recall, e.g. #' `myplot = tinyplot(...); myplot`. This behaviour can also be set globally #' via `tpar(record = TRUE)`; an explicit argument here takes precedence. +#' +#' Note that recording requires a device with an enabled display list (see +#' \code{\link[grDevices]{dev.control}}). Most interactive devices enable this +#' behaviour by default, whereas file-based devices do not. However `tinyplot` +#' automatically enables it for any device that it opens itself via `file`, +#' and further emits a warning if the current device is not recording. #' @param width numeric giving the plot width in inches. Together with `height`, #' typically used in conjunction with the `file` argument above, overriding the #' default values held in `tpar("file.width", "file.height")`. If either `width` diff --git a/R/tpar.R b/R/tpar.R index f471035a3..aa0bc4916 100644 --- a/R/tpar.R +++ b/R/tpar.R @@ -87,7 +87,7 @@ #' * `lwd.xaxs`, `lwd.yaxs`: Line widths for the x- and y-axis lines, respectively. Both default to `NULL`, whereby the shared `lwd.axis` value is used instead. #' * `palette.qualitative`: Palette for qualitative colors. See the `palette` argument in `?tinyplot`. #' * `palette.sequential`: Palette for sequential colors. See the `palette` argument in `?tinyplot`. -#' * `record`: Logical indicating whether `tinyplot()` should record plots and return them as replayable objects (see \code{\link[grDevices]{recordPlot}}). Defaults to `NULL`, which is equivalent to `FALSE`. Setting to `TRUE` allows for assignment and later recall, e.g. `myplot = tinyplot(...); myplot`. Sets the default for the `record` argument of [`tinyplot()`], which takes precedence. +#' * `record`: Logical indicating whether `tinyplot()` should record plots and return them as replayable objects (see \code{\link[grDevices]{recordPlot}}). Defaults to `NULL`, which is equivalent to `FALSE`. Setting to `TRUE` allows for assignment and later recall, e.g. `myplot = tinyplot(...); myplot`. Sets the default for the `record` argument of [`tinyplot()`], which takes precedence. Note that recording requires a device with an enabled display list (see \code{\link[grDevices]{dev.control}}). Most interactive devices enable this behaviour by default, whereas file-based devices do not. However `tinyplot()` automatically enables it for any device that it opens itself via `file`, and further emits a warning if the current device is not recording. #' * `ribbon.alpha`: Numeric factor in the range `[0,1]` for modifying the opacity alpha of "ribbon" and "area" type plots. Default value is `0.2`. #' * `xaxr`, `yaxr`: Numeric giving the rotation of the x- and y-axis tick labels, in degrees counter-clockwise; `NULL` (the default) leaves them unrotated. Unlike `las`, which is limited to the four right angles, any angle is permitted. Setting one overrides `las` for that axis alone, leaving the other axis under `las` as usual, and `0` (or any multiple of 360) counts as no rotation at all. Sets the default for the `xaxr` and `yaxr` arguments of [`tinyplot()`], which take precedence. Two caveats follow from tinyplot drawing rotated labels itself rather than deferring to base `axis()`. First, margins are only resized to fit them under a theme with `dynmar = TRUE` (see `tinytheme`); under the default theme the margins are left alone, so a long rotated label will be clipped unless you widen `mar` yourself. Second, rotated labels do not inherit the thinning that `axis()` applies via `gap.axis`, so they start to overlap once the spacing between ticks falls below `line height / sin(srt)`. #' diff --git a/man/tinyplot.Rd b/man/tinyplot.Rd index fdfa8386e..f58a54544 100644 --- a/man/tinyplot.Rd +++ b/man/tinyplot.Rd @@ -656,7 +656,13 @@ specified.} replayable object (see \code{\link[grDevices]{recordPlot}})? Defaults to \code{FALSE}. Setting to \code{TRUE} allows for assignment and later recall, e.g. \verb{myplot = tinyplot(...); myplot}. This behaviour can also be set globally -via \code{tpar(record = TRUE)}; an explicit argument here takes precedence.} +via \code{tpar(record = TRUE)}; an explicit argument here takes precedence. + +Note that recording requires a device with an enabled display list (see +\code{\link[grDevices]{dev.control}}). Most interactive devices enable this +behaviour by default, whereas file-based devices do not. However \code{tinyplot} +automatically enables it for any device that it opens itself via \code{file}, +and further emits a warning if the current device is not recording.} \item{asp}{the y/xy/x aspect ratio, see \code{plot.window}.} diff --git a/man/tpar.Rd b/man/tpar.Rd index 584395e13..02016fd6f 100644 --- a/man/tpar.Rd +++ b/man/tpar.Rd @@ -98,7 +98,7 @@ you should rather use \code{par()} instead. \item \code{lwd.xaxs}, \code{lwd.yaxs}: Line widths for the x- and y-axis lines, respectively. Both default to \code{NULL}, whereby the shared \code{lwd.axis} value is used instead. \item \code{palette.qualitative}: Palette for qualitative colors. See the \code{palette} argument in \code{?tinyplot}. \item \code{palette.sequential}: Palette for sequential colors. See the \code{palette} argument in \code{?tinyplot}. -\item \code{record}: Logical indicating whether \code{tinyplot()} should record plots and return them as replayable objects (see \code{\link[grDevices]{recordPlot}}). Defaults to \code{NULL}, which is equivalent to \code{FALSE}. Setting to \code{TRUE} allows for assignment and later recall, e.g. \verb{myplot = tinyplot(...); myplot}. Sets the default for the \code{record} argument of \code{\link[=tinyplot]{tinyplot()}}, which takes precedence. +\item \code{record}: Logical indicating whether \code{tinyplot()} should record plots and return them as replayable objects (see \code{\link[grDevices]{recordPlot}}). Defaults to \code{NULL}, which is equivalent to \code{FALSE}. Setting to \code{TRUE} allows for assignment and later recall, e.g. \verb{myplot = tinyplot(...); myplot}. Sets the default for the \code{record} argument of \code{\link[=tinyplot]{tinyplot()}}, which takes precedence. Note that recording requires a device with an enabled display list (see \code{\link[grDevices]{dev.control}}). Most interactive devices enable this behaviour by default, whereas file-based devices do not. However \code{tinyplot()} automatically enables it for any device that it opens itself via \code{file}, and further emits a warning if the current device is not recording. \item \code{ribbon.alpha}: Numeric factor in the range \verb{[0,1]} for modifying the opacity alpha of "ribbon" and "area" type plots. Default value is \code{0.2}. \item \code{xaxr}, \code{yaxr}: Numeric giving the rotation of the x- and y-axis tick labels, in degrees counter-clockwise; \code{NULL} (the default) leaves them unrotated. Unlike \code{las}, which is limited to the four right angles, any angle is permitted. Setting one overrides \code{las} for that axis alone, leaving the other axis under \code{las} as usual, and \code{0} (or any multiple of 360) counts as no rotation at all. Sets the default for the \code{xaxr} and \code{yaxr} arguments of \code{\link[=tinyplot]{tinyplot()}}, which take precedence. Two caveats follow from tinyplot drawing rotated labels itself rather than deferring to base \code{axis()}. First, margins are only resized to fit them under a theme with \code{dynmar = TRUE} (see \code{tinytheme}); under the default theme the margins are left alone, so a long rotated label will be clipped unless you widen \code{mar} yourself. Second, rotated labels do not inherit the thinning that \code{axis()} applies via \code{gap.axis}, so they start to overlap once the spacing between ticks falls below \verb{line height / sin(srt)}. } From d678682c43157cd8eb3ac0bb8596af05f39d2a38 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Thu, 10 Sep 2026 11:41:38 -0700 Subject: [PATCH 04/18] document and methods --- NAMESPACE | 1 + R/tinyplot.data.frame.R | 2 +- R/tinyplot.matrix.R | 2 +- R/tinyplot.ts.R | 2 +- R/tinyplot_add.R | 2 +- man/tinyplot.data.frame.Rd | 5 ++++- man/tinyplot.matrix.Rd | 5 ++++- man/tinyplot.ts.Rd | 5 ++++- man/tinyplot_add.Rd | 5 ++++- 9 files changed, 21 insertions(+), 8 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 23a90fa9f..90a5a7e92 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -64,6 +64,7 @@ importFrom(grDevices, col2rgb, colorRampPalette, convertColor, + dev.control, dev.cur, dev.list, dev.new, diff --git a/R/tinyplot.data.frame.R b/R/tinyplot.data.frame.R index 1633a4714..6cfc04d41 100644 --- a/R/tinyplot.data.frame.R +++ b/R/tinyplot.data.frame.R @@ -37,7 +37,7 @@ #' to disambiguate from `frame.plot`. #' @param ... further arguments passed to `tinyplot`. #' -#' @returns No return value, called for the side effect of producing a plot. +#' @inherit tinyplot return #' #' @examples #' ## using tinyplot() with data frames diff --git a/R/tinyplot.matrix.R b/R/tinyplot.matrix.R index f031ce2c2..61ebd6419 100644 --- a/R/tinyplot.matrix.R +++ b/R/tinyplot.matrix.R @@ -40,7 +40,7 @@ #' titles default to `NA`, since the dimnames label both axes. #' @param ... further arguments passed to `tinyplot`. #' -#' @returns No return value, called for the side effect of producing a plot. +#' @inherit tinyplot return #' #' @seealso \code{\link[graphics]{matplot}} #' diff --git a/R/tinyplot.ts.R b/R/tinyplot.ts.R index b578dc8e0..05e77a1ba 100644 --- a/R/tinyplot.ts.R +++ b/R/tinyplot.ts.R @@ -25,7 +25,7 @@ #' multivariate series. #' @param type,facet.args,xlab,ylab,... further arguments passed to `tinyplot`. #' -#' @returns No return value, called for the side effect of producing a plot. +#' @inherit tinyplot return #' #' @examples #' ## univariate series diff --git a/R/tinyplot_add.R b/R/tinyplot_add.R index 4b4d4f5cb..e39a8d3c4 100644 --- a/R/tinyplot_add.R +++ b/R/tinyplot_add.R @@ -44,7 +44,7 @@ #' tinyplot(mpg ~ wt, data = mtcars) #' tinyplot_add(subset = cyl == 4, col = "red", pch = 16) #' -#' @returns No return value, called for side effect of producing a plot. +#' @inherit tinyplot return #' #' @export tinyplot_add = function(...) { diff --git a/man/tinyplot.data.frame.Rd b/man/tinyplot.data.frame.Rd index 933f471ec..f16483ae6 100644 --- a/man/tinyplot.data.frame.Rd +++ b/man/tinyplot.data.frame.Rd @@ -37,7 +37,10 @@ to disambiguate from \code{frame.plot}.} \item{...}{further arguments passed to \code{tinyplot}.} } \value{ -No return value, called for the side effect of producing a plot. +By default, no return value; called for the side effect of producing +a plot. If \code{record = TRUE} (or globally via \code{tpar(record = TRUE)}), the +plot is instead returned invisibly as a \code{"recordedplot"} object, which can +be replayed later; see \code{\link[grDevices]{recordPlot}}. } \description{ Convenience interface for visualizing diff --git a/man/tinyplot.matrix.Rd b/man/tinyplot.matrix.Rd index ad9084486..f90ddef17 100644 --- a/man/tinyplot.matrix.Rd +++ b/man/tinyplot.matrix.Rd @@ -36,7 +36,10 @@ titles default to \code{NA}, since the dimnames label both axes.} \item{...}{further arguments passed to \code{tinyplot}.} } \value{ -No return value, called for the side effect of producing a plot. +By default, no return value; called for the side effect of producing +a plot. If \code{record = TRUE} (or globally via \code{tpar(record = TRUE)}), the +plot is instead returned invisibly as a \code{"recordedplot"} object, which can +be replayed later; see \code{\link[grDevices]{recordPlot}}. } \description{ Convenience interface for visualizing diff --git a/man/tinyplot.ts.Rd b/man/tinyplot.ts.Rd index 2efaed63f..cbc93e53b 100644 --- a/man/tinyplot.ts.Rd +++ b/man/tinyplot.ts.Rd @@ -25,7 +25,10 @@ multivariate series.} \item{type, facet.args, xlab, ylab, ...}{further arguments passed to \code{tinyplot}.} } \value{ -No return value, called for the side effect of producing a plot. +By default, no return value; called for the side effect of producing +a plot. If \code{record = TRUE} (or globally via \code{tpar(record = TRUE)}), the +plot is instead returned invisibly as a \code{"recordedplot"} object, which can +be replayed later; see \code{\link[grDevices]{recordPlot}}. } \description{ Convenience interface for visualizing \code{\link[stats]{ts}} diff --git a/man/tinyplot_add.Rd b/man/tinyplot_add.Rd index ba4634ab9..0735b6fa2 100644 --- a/man/tinyplot_add.Rd +++ b/man/tinyplot_add.Rd @@ -18,7 +18,10 @@ so those that rely on non-standard evaluation against \code{data}---e.g. \code{\link{tinyplot}} call.} } \value{ -No return value, called for side effect of producing a plot. +By default, no return value; called for the side effect of producing +a plot. If \code{record = TRUE} (or globally via \code{tpar(record = TRUE)}), the +plot is instead returned invisibly as a \code{"recordedplot"} object, which can +be replayed later; see \code{\link[grDevices]{recordPlot}}. } \description{ This convenience function grabs the preceding \code{tinyplot} call and updates it From d3ee6048fe63eef845b3b3c9ba9bedb58958f61e Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Thu, 10 Sep 2026 11:54:53 -0700 Subject: [PATCH 05/18] docs: arg order --- R/tinyplot.R | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/R/tinyplot.R b/R/tinyplot.R index 089b2740e..7f06a0113 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -467,17 +467,6 @@ #' \code{\link[tinyplot]{tpar}} (i.e., both defaulting to 7 inches, and where #' the default resolution for bitmap files is also specified as 300 #' DPI). -#' @param record logical. Should the plot be recorded and returned as a -#' replayable object (see \code{\link[grDevices]{recordPlot}})? Defaults to -#' `FALSE`. Setting to `TRUE` allows for assignment and later recall, e.g. -#' `myplot = tinyplot(...); myplot`. This behaviour can also be set globally -#' via `tpar(record = TRUE)`; an explicit argument here takes precedence. -#' -#' Note that recording requires a device with an enabled display list (see -#' \code{\link[grDevices]{dev.control}}). Most interactive devices enable this -#' behaviour by default, whereas file-based devices do not. However `tinyplot` -#' automatically enables it for any device that it opens itself via `file`, -#' and further emits a warning if the current device is not recording. #' @param width numeric giving the plot width in inches. Together with `height`, #' typically used in conjunction with the `file` argument above, overriding the #' default values held in `tpar("file.width", "file.height")`. If either `width` @@ -489,6 +478,17 @@ #' @param height numeric giving the plot height in inches. Same considerations as #' `width` (above) apply, e.g. will default to `tpar("file.height")` if not #' specified. +#' @param record logical. Should the plot be recorded and returned as a +#' replayable object (see \code{\link[grDevices]{recordPlot}})? Defaults to +#' `FALSE`. Setting to `TRUE` allows for assignment and later recall, e.g. +#' `myplot = tinyplot(...); myplot`. This behaviour can also be set globally +#' via `tpar(record = TRUE)`; an explicit argument here takes precedence. +#' +#' Note that recording requires a device with an enabled display list (see +#' \code{\link[grDevices]{dev.control}}). Most interactive devices enable this +#' behaviour by default, whereas file-based devices do not. However `tinyplot` +#' automatically enables it for any device that it opens itself via `file`, +#' and further emits a warning if the current device is not recording. #' @param asp the y/xy/x aspect ratio, see `plot.window`. #' @param theme keyword string (e.g. `"clean"`) or list defining a theme. Passed #' on to [`tinytheme`], but reset upon exit so that the theme effect is only From 3b4e2aaae9a3dd8f10732cf8a8357e0194e7c9dd Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Thu, 10 Sep 2026 12:12:02 -0700 Subject: [PATCH 06/18] tests --- inst/tinytest/_tinysnapshot/record_replay.svg | 239 ++++++++++++++++++ inst/tinytest/test-record.R | 80 ++++++ 2 files changed, 319 insertions(+) create mode 100644 inst/tinytest/_tinysnapshot/record_replay.svg create mode 100644 inst/tinytest/test-record.R diff --git a/inst/tinytest/_tinysnapshot/record_replay.svg b/inst/tinytest/_tinysnapshot/record_replay.svg new file mode 100644 index 000000000..e2854b229 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/record_replay.svg @@ -0,0 +1,239 @@ + + + + + + + + + + + + + + + + +Species +setosa +versicolor +virginica + + + + + + + +Petal.Length +Sepal.Length + + + + + + + + + + +1 +2 +3 +4 +5 +6 +7 + + + + + + + + + +4.5 +5.0 +5.5 +6.0 +6.5 +7.0 +7.5 +8.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/test-record.R b/inst/tinytest/test-record.R new file mode 100644 index 000000000..e0ea134a5 --- /dev/null +++ b/inst/tinytest/test-record.R @@ -0,0 +1,80 @@ +source("helpers.R") +using("tinysnapshot") + +# Unlike the other test files, this one manages devices explicitly: whether a +# device records is exactly what `record` depends on, so it is the fixture +# rather than incidental setup. pdf(NULL) is a sink that draws nowhere and +# writes no file, and like all file devices it starts with its display list +# inhibited -- which is what makes it useful for both cases below. +recording_device = function() { + pdf(NULL) + dev.control(displaylist = "enable") # as interactive devices do by default +} +nonrecording_device = function() { + pdf(NULL) # as file-based devices are by default +} + + +# `record` is opt-in: the default return value is NULL, as before. +recording_device() +expect_null(tinyplot(Sepal.Length ~ Petal.Length | Species, data = iris)) + +# record = TRUE returns a replayable object with a non-empty display list +p = tinyplot(Sepal.Length ~ Petal.Length | Species, data = iris, record = TRUE) +expect_inherits(p, "recordedplot") +expect_true(length(p[[1]]) > 0L) + +# tpar() sets the default, and an explicit argument takes precedence over it +tpar(record = TRUE) +expect_inherits(tinyplot(Sepal.Length ~ Petal.Length, data = iris), "recordedplot") +expect_null(tinyplot(Sepal.Length ~ Petal.Length, data = iris, record = FALSE)) +tpar(record = NULL) +expect_null(tinyplot(Sepal.Length ~ Petal.Length, data = iris)) +dev.off() + +# A non-recording device yields an empty plot; warn rather than hand back +# something that silently replays blank. +nonrecording_device() +expect_warning( + tinyplot(Sepal.Length ~ Petal.Length, data = iris, record = TRUE), + pattern = "not recording" +) +dev.off() + +# tinyplot enables the display list on devices it opens itself, so record = TRUE +# and file = must compose (no enclosing device here: `file` supplies its own). +tmp = tempfile(fileext = ".png") +q = tinyplot(Sepal.Length ~ Petal.Length | Species, data = iris, + record = TRUE, file = tmp) +expect_inherits(q, "recordedplot") +expect_true(length(q[[1]]) > 0L) +expect_true(file.exists(tmp)) +unlink(tmp) + +# ... and without record, file = still returns NULL +tmp2 = tempfile(fileext = ".png") +expect_null(tinyplot(Sepal.Length ~ Petal.Length, data = iris, file = tmp2)) +unlink(tmp2) + +# invalid input +nonrecording_device() +expect_error( + tinyplot(Sepal.Length ~ Petal.Length, data = iris, record = "yes"), + pattern = "record" +) +dev.off() +expect_error(tpar(record = "yes"), pattern = "record") +# NB: tpar() assigns before it validates, so a rejected value sticks and would +# poison every later call in this file. Reset it explicitly. +tpar(record = NULL) + +# A recorded plot replays to the same output as the original draw. +if (snapshots_run) { + svglite::svglite(tempfile(fileext = ".svg"), width = 7, height = 7) + dev.control(displaylist = "enable") + rec = tinyplot(Sepal.Length ~ Petal.Length | Species, data = iris, record = TRUE) + dev.off() + expect_snapshot_plot(function() replayPlot(rec), label = "record_replay") +} + +tpar(record = NULL) From 5c934f75208e199dab724623085fc0ae9b5958ff Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Thu, 10 Sep 2026 12:29:54 -0700 Subject: [PATCH 07/18] fix CI --- inst/tinytest/_tinysnapshot/record_replay.svg | 239 ------------------ inst/tinytest/test-record.R | 30 ++- 2 files changed, 24 insertions(+), 245 deletions(-) delete mode 100644 inst/tinytest/_tinysnapshot/record_replay.svg diff --git a/inst/tinytest/_tinysnapshot/record_replay.svg b/inst/tinytest/_tinysnapshot/record_replay.svg deleted file mode 100644 index e2854b229..000000000 --- a/inst/tinytest/_tinysnapshot/record_replay.svg +++ /dev/null @@ -1,239 +0,0 @@ - - - - - - - - - - - - - - - - -Species -setosa -versicolor -virginica - - - - - - - -Petal.Length -Sepal.Length - - - - - - - - - - -1 -2 -3 -4 -5 -6 -7 - - - - - - - - - -4.5 -5.0 -5.5 -6.0 -6.5 -7.0 -7.5 -8.0 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/inst/tinytest/test-record.R b/inst/tinytest/test-record.R index e0ea134a5..60403a41b 100644 --- a/inst/tinytest/test-record.R +++ b/inst/tinytest/test-record.R @@ -68,13 +68,31 @@ expect_error(tpar(record = "yes"), pattern = "record") # poison every later call in this file. Reset it explicitly. tpar(record = NULL) -# A recorded plot replays to the same output as the original draw. -if (snapshots_run) { - svglite::svglite(tempfile(fileext = ".svg"), width = 7, height = 7) - dev.control(displaylist = "enable") - rec = tinyplot(Sepal.Length ~ Petal.Length | Species, data = iris, record = TRUE) +# A recorded plot replays to exactly the same output as the original draw. +# Deliberately not a snapshot test: replaying onto a *different* device than the +# one that recorded is not faithful (text metrics differ by backend), and the +# snapshot device is configured separately from whatever records here. Comparing +# two files produced in this session sidesteps that and tests the real property. +if (requireNamespace("svglite", quietly = TRUE)) { + draw = function(f, record = FALSE) { + svglite::svglite(f, width = 7, height = 7) + on.exit(dev.off()) + if (record) dev.control(displaylist = "enable") + tinyplot(Sepal.Length ~ Petal.Length | Species, data = iris, record = record) + } + f_native = tempfile(fileext = ".svg") + f_replay = tempfile(fileext = ".svg") + draw(f_native) + rec = draw(tempfile(fileext = ".svg"), record = TRUE) + svglite::svglite(f_replay, width = 7, height = 7) + replayPlot(rec) dev.off() - expect_snapshot_plot(function() replayPlot(rec), label = "record_replay") + native = readLines(f_native, warn = FALSE) + replay = readLines(f_replay, warn = FALSE) + # guard against the comparison passing vacuously on two empty files + expect_true(length(native) > 10L) + expect_identical(replay, native) + unlink(c(f_native, f_replay)) } tpar(record = NULL) From 71e2d8b96c32b082c26b7c6660265471d8c596a2 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Thu, 10 Sep 2026 14:16:31 -0700 Subject: [PATCH 08/18] mark as experimental - at the same time, drop the (now stale) experimental tag for "draw" --- R/tinyplot.R | 15 ++++++++------- R/tpar.R | 2 +- man/tinyplot.Rd | 15 ++++++++------- man/tpar.Rd | 2 +- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/R/tinyplot.R b/R/tinyplot.R index 7f06a0113..804c78048 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -430,10 +430,10 @@ #' `y` are plotted). The `draw` argument is primarily useful for adding common #' elements to each facet of a faceted plot, e.g. #' \code{\link[graphics]{abline}} or \code{\link[graphics]{text}}. Note that -#' this argument is somewhat experimental and that _no_ internal checking is -#' done for correctness; the provided argument is simply captured and -#' evaluated as-is within `tinyplot()` and thus has access to the local -#' definition of all variables such as `x`, `y`, etc. See Examples. +#' _no_ internal checking is done for correctness; the provided argument is +#' simply captured and evaluated as-is within `tinyplot()` and thus has +#' access to the local definition of all variables such as `x`, `y`, etc. +#' See Examples. #' @param restore.par a logical value indicating whether the #' \code{\link[graphics]{par}} settings prior to calling `tinyplot` should be #' restored on exit. Defaults to FALSE, which makes it possible to add @@ -478,9 +478,10 @@ #' @param height numeric giving the plot height in inches. Same considerations as #' `width` (above) apply, e.g. will default to `tpar("file.height")` if not #' specified. -#' @param record logical. Should the plot be recorded and returned as a -#' replayable object (see \code{\link[grDevices]{recordPlot}})? Defaults to -#' `FALSE`. Setting to `TRUE` allows for assignment and later recall, e.g. +#' @param record (experimental) a logical value indicating whether the plot +#' should be recorded and returned as a replayable object (see +#' \code{\link[grDevices]{recordPlot}}). Defaults to `FALSE`. Setting to +#' `TRUE` allows for assignment and later recall, e.g. #' `myplot = tinyplot(...); myplot`. This behaviour can also be set globally #' via `tpar(record = TRUE)`; an explicit argument here takes precedence. #' diff --git a/R/tpar.R b/R/tpar.R index aa0bc4916..30398fa66 100644 --- a/R/tpar.R +++ b/R/tpar.R @@ -87,7 +87,7 @@ #' * `lwd.xaxs`, `lwd.yaxs`: Line widths for the x- and y-axis lines, respectively. Both default to `NULL`, whereby the shared `lwd.axis` value is used instead. #' * `palette.qualitative`: Palette for qualitative colors. See the `palette` argument in `?tinyplot`. #' * `palette.sequential`: Palette for sequential colors. See the `palette` argument in `?tinyplot`. -#' * `record`: Logical indicating whether `tinyplot()` should record plots and return them as replayable objects (see \code{\link[grDevices]{recordPlot}}). Defaults to `NULL`, which is equivalent to `FALSE`. Setting to `TRUE` allows for assignment and later recall, e.g. `myplot = tinyplot(...); myplot`. Sets the default for the `record` argument of [`tinyplot()`], which takes precedence. Note that recording requires a device with an enabled display list (see \code{\link[grDevices]{dev.control}}). Most interactive devices enable this behaviour by default, whereas file-based devices do not. However `tinyplot()` automatically enables it for any device that it opens itself via `file`, and further emits a warning if the current device is not recording. +#' * `record`: (experimental) Logical indicating whether `tinyplot()` should record plots and return them as replayable objects (see \code{\link[grDevices]{recordPlot}}). Defaults to `NULL`, which is equivalent to `FALSE`. Setting to `TRUE` allows for assignment and later recall, e.g. `myplot = tinyplot(...); myplot`. Sets the default for the `record` argument of [`tinyplot()`], which takes precedence. Note that recording requires a device with an enabled display list (see \code{\link[grDevices]{dev.control}}). Most interactive devices enable this behaviour by default, whereas file-based devices do not. However `tinyplot()` automatically enables it for any device that it opens itself via `file`, and further emits a warning if the current device is not recording. #' * `ribbon.alpha`: Numeric factor in the range `[0,1]` for modifying the opacity alpha of "ribbon" and "area" type plots. Default value is `0.2`. #' * `xaxr`, `yaxr`: Numeric giving the rotation of the x- and y-axis tick labels, in degrees counter-clockwise; `NULL` (the default) leaves them unrotated. Unlike `las`, which is limited to the four right angles, any angle is permitted. Setting one overrides `las` for that axis alone, leaving the other axis under `las` as usual, and `0` (or any multiple of 360) counts as no rotation at all. Sets the default for the `xaxr` and `yaxr` arguments of [`tinyplot()`], which take precedence. Two caveats follow from tinyplot drawing rotated labels itself rather than deferring to base `axis()`. First, margins are only resized to fit them under a theme with `dynmar = TRUE` (see `tinytheme`); under the default theme the margins are left alone, so a long rotated label will be clipped unless you widen `mar` yourself. Second, rotated labels do not inherit the thinning that `axis()` applies via `gap.axis`, so they start to overlap once the spacing between ticks falls below `line height / sin(srt)`. #' diff --git a/man/tinyplot.Rd b/man/tinyplot.Rd index f58a54544..546db41e6 100644 --- a/man/tinyplot.Rd +++ b/man/tinyplot.Rd @@ -598,10 +598,10 @@ existing plot without having to repeat arguments.} \code{y} are plotted). The \code{draw} argument is primarily useful for adding common elements to each facet of a faceted plot, e.g. \code{\link[graphics]{abline}} or \code{\link[graphics]{text}}. Note that -this argument is somewhat experimental and that \emph{no} internal checking is -done for correctness; the provided argument is simply captured and -evaluated as-is within \code{tinyplot()} and thus has access to the local -definition of all variables such as \code{x}, \code{y}, etc. See Examples.} +\emph{no} internal checking is done for correctness; the provided argument is +simply captured and evaluated as-is within \code{tinyplot()} and thus has +access to the local definition of all variables such as \code{x}, \code{y}, etc. +See Examples.} \item{empty}{logical indicating whether the interior plot region should be left empty. The default is \code{FALSE}. Setting to \code{TRUE} has a similar effect @@ -652,9 +652,10 @@ graphics windows.} \code{width} (above) apply, e.g. will default to \code{tpar("file.height")} if not specified.} -\item{record}{logical. Should the plot be recorded and returned as a -replayable object (see \code{\link[grDevices]{recordPlot}})? Defaults to -\code{FALSE}. Setting to \code{TRUE} allows for assignment and later recall, e.g. +\item{record}{(experimental) a logical value indicating whether the plot +should be recorded and returned as a replayable object (see +\code{\link[grDevices]{recordPlot}}). Defaults to \code{FALSE}. Setting to +\code{TRUE} allows for assignment and later recall, e.g. \verb{myplot = tinyplot(...); myplot}. This behaviour can also be set globally via \code{tpar(record = TRUE)}; an explicit argument here takes precedence. diff --git a/man/tpar.Rd b/man/tpar.Rd index 02016fd6f..01c6a1ee5 100644 --- a/man/tpar.Rd +++ b/man/tpar.Rd @@ -98,7 +98,7 @@ you should rather use \code{par()} instead. \item \code{lwd.xaxs}, \code{lwd.yaxs}: Line widths for the x- and y-axis lines, respectively. Both default to \code{NULL}, whereby the shared \code{lwd.axis} value is used instead. \item \code{palette.qualitative}: Palette for qualitative colors. See the \code{palette} argument in \code{?tinyplot}. \item \code{palette.sequential}: Palette for sequential colors. See the \code{palette} argument in \code{?tinyplot}. -\item \code{record}: Logical indicating whether \code{tinyplot()} should record plots and return them as replayable objects (see \code{\link[grDevices]{recordPlot}}). Defaults to \code{NULL}, which is equivalent to \code{FALSE}. Setting to \code{TRUE} allows for assignment and later recall, e.g. \verb{myplot = tinyplot(...); myplot}. Sets the default for the \code{record} argument of \code{\link[=tinyplot]{tinyplot()}}, which takes precedence. Note that recording requires a device with an enabled display list (see \code{\link[grDevices]{dev.control}}). Most interactive devices enable this behaviour by default, whereas file-based devices do not. However \code{tinyplot()} automatically enables it for any device that it opens itself via \code{file}, and further emits a warning if the current device is not recording. +\item \code{record}: (experimental) Logical indicating whether \code{tinyplot()} should record plots and return them as replayable objects (see \code{\link[grDevices]{recordPlot}}). Defaults to \code{NULL}, which is equivalent to \code{FALSE}. Setting to \code{TRUE} allows for assignment and later recall, e.g. \verb{myplot = tinyplot(...); myplot}. Sets the default for the \code{record} argument of \code{\link[=tinyplot]{tinyplot()}}, which takes precedence. Note that recording requires a device with an enabled display list (see \code{\link[grDevices]{dev.control}}). Most interactive devices enable this behaviour by default, whereas file-based devices do not. However \code{tinyplot()} automatically enables it for any device that it opens itself via \code{file}, and further emits a warning if the current device is not recording. \item \code{ribbon.alpha}: Numeric factor in the range \verb{[0,1]} for modifying the opacity alpha of "ribbon" and "area" type plots. Default value is \code{0.2}. \item \code{xaxr}, \code{yaxr}: Numeric giving the rotation of the x- and y-axis tick labels, in degrees counter-clockwise; \code{NULL} (the default) leaves them unrotated. Unlike \code{las}, which is limited to the four right angles, any angle is permitted. Setting one overrides \code{las} for that axis alone, leaving the other axis under \code{las} as usual, and \code{0} (or any multiple of 360) counts as no rotation at all. Sets the default for the \code{xaxr} and \code{yaxr} arguments of \code{\link[=tinyplot]{tinyplot()}}, which take precedence. Two caveats follow from tinyplot drawing rotated labels itself rather than deferring to base \code{axis()}. First, margins are only resized to fit them under a theme with \code{dynmar = TRUE} (see \code{tinytheme}); under the default theme the margins are left alone, so a long rotated label will be clipped unless you widen \code{mar} yourself. Second, rotated labels do not inherit the thinning that \code{axis()} applies via \code{gap.axis}, so they start to overlap once the spacing between ticks falls below \verb{line height / sin(srt)}. } From 5e487633ab22381639464a1a30f59a89499b1ef5 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Thu, 10 Sep 2026 14:43:48 -0700 Subject: [PATCH 09/18] news --- NEWS.md | 55 +++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/NEWS.md b/NEWS.md index 7f61fbc37..4f4c984f0 100644 --- a/NEWS.md +++ b/NEWS.md @@ -138,26 +138,27 @@ related to plot layering. See "Bug fixes" below. #### Other new features -- New top-level `xaxr` and `yaxr` arguments allow rotating of the x- and y-axis - tick labels by arbitrary angles, closing a long-standing feature request - (#346). Note that setting one overrides `las` for that axis. Best combined - with a dynamic theme, since the plot margins are resized to fit the rotated - labels. Also settable via `tpar("x/yaxr")` and thus as part of a `tinytheme` - too. (#717 @grantmcdermott) -- Custom plot types have more control over the surrounding plot machinery, via a - new `type_hints` mechanism. A type can declare properties about itself---that - it draws its own axes, needs a secondary right-hand axis, uses proportional - limits, fills its legend key from `col`, and so on---and **tinyplot** adjusts - margins, axis limits and legend keys accordingly. Previously this behaviour - was hard-coded against the names of built-in types, so it was unavailable to - custom types. See - [Advanced customization](https://grantmcdermott.com/tinyplot/vignettes/types.html#type-hints) - in the `Types` vignette for the list of supported hints. (#543 @grantmcdermott) -- New `cex.xaxs` and `cex.yaxs` graphical parameters allow the x- and y-axis - tick labels to be sized independently, e.g. `tpar(cex.yaxs = 0.6)` to shrink a - long list of category names on the y-axis without also shrinking the x-axis. - Both default to `NULL`, in which case the shared `cex.axis` value is used, so - existing plots are unaffected. (#677 @grantmcdermott) +- New top-level `tinyplot()`/`plt()` arguments: + - `xaxr` and `yaxr` enable rotating of the x- and y-axis tick labels by + arbitrary angles, closing a long-standing feature request (#346). Note that + setting one overrides `las` for that axis. Best combined with a dynamic + theme, since the plot margins are resized to fit the rotated labels. Also + settable globally via `tpar("x/yaxr")` and thus as part of a `tinytheme` + too. (#717 @grantmcdermott) + - (Experimental) `record` enables recording plots as replayable objects, + closing another long-standing feature request (#121). Specifically, setting + `record = TRUE` returns a `"recordedplot"` object (see + `?grDevices::recordPlot`), allowing for assignment and later recall, e.g. + `myplot = tinyplot(...); myplot`. Also settable globally via + `tpar(record = TRUE)`, so that all `tinyplot()` plots are automatically + recorded---with potential memory implications for detailed plots with _many_ + elements. Note that a recorded plot will still render its display on the + initialising call if you are using an interactive graphics device. While we + have done our best to vet this new `record` functionality carefully, users + should still regard it as experimental. We may change its behaviour + (availability) in a future release if we observe undesirable side-effects. + Please help us by testing on your own machines and reporting any issues. + (#686 @grantmcdermott) - Type-specific updates: - `type_lines()` and its shortcut equivalents like `"l"` and `"s"` now support a _continuous_ `by` variable, drawing a colour gradient along the line @@ -188,6 +189,20 @@ related to plot layering. See "Bug fixes" below. so that tiles meet the panel edge, and also rotates the tick labels against their respective axes. Colour fills default to the "tealgrn" sequential palette. (#677 @grantmcdermott) +- Custom plot types have more control over the surrounding plot machinery, via a + new `type_hints` mechanism. A type can declare properties about itself---that + it draws its own axes, needs a secondary right-hand axis, uses proportional + limits, fills its legend key from `col`, and so on---and **tinyplot** adjusts + margins, axis limits and legend keys accordingly. Previously this behaviour + was hard-coded against the names of built-in types, so it was unavailable to + custom types. See + [Advanced customization](https://grantmcdermott.com/tinyplot/vignettes/types.html#type-hints) + in the `Types` vignette for the list of supported hints. (#543 @grantmcdermott) +- New `cex.xaxs` and `cex.yaxs` graphical parameters allow the x- and y-axis + tick labels to be sized independently, e.g. `tpar(cex.yaxs = 0.6)` to shrink a + long list of category names on the y-axis without also shrinking the x-axis. + Both default to `NULL`, in which case the shared `cex.axis` value is used, so + existing plots are unaffected. (#677 @grantmcdermott) ### Bug fixes From 27e35df7d770ff63c55321ba0cdb637db563ae4e Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Thu, 10 Sep 2026 18:06:18 -0700 Subject: [PATCH 10/18] dedicated recordedtinyplot class (wrapper) --- NAMESPACE | 3 + R/record.R | 146 ++++++++++++++++++++++++++++++++++++ R/tinyplot.R | 12 +-- R/tpar.R | 2 +- inst/tinytest/test-record.R | 41 +++++++++- man/recordedtinyplot.Rd | 59 +++++++++++++++ man/tinyplot.Rd | 8 +- man/tinyplot.data.frame.Rd | 4 +- man/tinyplot.matrix.Rd | 4 +- man/tinyplot.ts.Rd | 4 +- man/tinyplot_add.Rd | 4 +- man/tpar.Rd | 2 +- 12 files changed, 268 insertions(+), 21 deletions(-) create mode 100644 R/record.R create mode 100644 man/recordedtinyplot.Rd diff --git a/NAMESPACE b/NAMESPACE index 90a5a7e92..441effcd4 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,5 +1,7 @@ # Generated by roxygen2: do not edit by hand +S3method(print,recordedtinyplot) +S3method(str,recordedtinyplot) S3method(tinyplot,data.frame) S3method(tinyplot,default) S3method(tinyplot,density) @@ -84,6 +86,7 @@ importFrom(grDevices, png, recordGraphics, recordPlot, + replayPlot, svg, xy.coords ) diff --git a/R/record.R b/R/record.R new file mode 100644 index 000000000..00eff3ee1 --- /dev/null +++ b/R/record.R @@ -0,0 +1,146 @@ +## Recorded tinyplot objects ----- +## +## A plain "recordedplot" carries the display list and nothing else, so +## replaying one restores the pixels but not the context that tinyplot_add() +## needs. The result is that +## +## p = tinyplot(..., record = TRUE); tinyplot(1); p; tinyplot_add(type = "lm") +## +## layers onto tinyplot(1) rather than onto p, because .last_call still points +## at the intervening plot. Wrapping the recording lets us carry that state +## along and put it back when the plot is replayed. + + +# Plot-scoped entries of .tinyplot_env: everything tinyplot_add() and the +# layering machinery need in order to treat a replayed plot as "the current +# plot". Deliberately excludes package-level config that is not tied to any +# single plot: .base_par_names (a device cache), .registered_themes and +# .tpar_hooks (user/session settings), and .saved_par_first (the session's +# baseline par, which a replay has no business overwriting). +recorded_state_keys = c( + ".last_call", + ".saved_par_before", + ".saved_par_after", + ".group_offsets", + ".offsets_axis", + ".facet_labs", + ".top_legend_soma", + "usr_orig", + "dev_orig", + "xlabs_orig" +) + + +# Snapshot the plot-scoped state, to be stashed on a recorded plot. +capture_record_state = function() { + out = lapply(recorded_state_keys, function(k) .tinyplot_env[[k]]) + names(out) = recorded_state_keys + # Drop `record` from the stored call. tinyplot_add() rebuilds the last call, + # so leaving it in would make every layer added after a replay record itself + # too -- and warn if that device happens not to be recording. Recording + # describes how one call returned its value, not a property of the plot to be + # inherited; pass `record = TRUE` to tinyplot_add() explicitly to record a + # layered plot. + cal = out[[".last_call"]] + if (is.call(cal) && "record" %in% names(as.list(cal))) { + cal[["record"]] = NULL + out[[".last_call"]] = cal + } + return(out) +} + + +# Put a snapshot back, so that a subsequent tinyplot_add() sees the replayed +# plot as the current one. +restore_record_state = function(state) { + if (!is.list(state)) return(invisible(NULL)) + for (k in intersect(names(state), recorded_state_keys)) { + .tinyplot_env[[k]] = state[[k]] + } + return(invisible(NULL)) +} + + +# Wrap a recordedplot, stashing the state alongside it. The result still +# inherits from "recordedplot", so replayPlot() and everything else that +# expects a bare recording keep working. +as_recordedtinyplot = function(rec) { + attr(rec, "tinyplot_state") = capture_record_state() + class(rec) = c("recordedtinyplot", "recordedplot") + return(rec) +} + + +#' @title Recorded tinyplot objects +#' +#' @description Objects of class `recordedtinyplot` are returned by +#' `tinyplot(..., record = TRUE)`. They are a thin wrapper around +#' \code{\link[grDevices]{recordPlot}}---thus conferring the same replay +#' functionality via \code{\link[grDevices]{replayPlot}}---but with the +#' initializing plot call and state added, so that recorded (tiny)plots play +#' nicely with \code{\link{tinyplot_add}()} and friends. +#' +#' @param x a `recordedtinyplot` object, returned by a +#' `tinyplot(..., record = TRUE)` call. +#' @param ... further arguments passed to \code{\link[grDevices]{replayPlot}}. +#' +#' @returns `print()` replays the plot and returns `x` invisibly. +#' +#' @details Recording requires a device whose display list is enabled (see +#' \code{\link[grDevices]{dev.control}}); without one there is nothing to +#' record and the returned plot will replay blank. Interactive devices---the +#' plot pane of an IDE, say---typically enable it by default, whereas +#' file-based devices (`png`, `pdf`, `svg`, ...) do not. `tinyplot()` enables +#' it for any device that it opens itself via the `file` argument, and warns +#' if the current device is not recording. +#' +#' This class is experimental, as is the `record` argument that produces it. +#' +#' @seealso \code{\link[grDevices]{recordPlot}} and +#' \code{\link[grDevices]{replayPlot}}, which this class wraps and defers to +#' for the actual recording and replaying. +#' \code{\link[grDevices]{dev.control}} for enabling a device's display list, +#' without which there is nothing to record. +#' +#' @examples +#' \dontrun{ +#' p = tinyplot(Sepal.Length ~ Petal.Length | Species, data = iris, +#' record = TRUE) +#' tinyplot(1:10) # some other plot +#' p # replay, restoring plot context +#' tinyplot_add(type = "lm") # layers onto p, not onto the 1:10 plot +#' } +#' +#' @name recordedtinyplot +#' @importFrom grDevices replayPlot +#' @export +print.recordedtinyplot = function(x, ...) { + # Strip our class before handing off, so we replay as a plain recording + # rather than recursing back into this method. + rec = x + attr(rec, "tinyplot_state") = NULL + class(rec) = "recordedplot" + replayPlot(rec, ...) + # Only now adopt the replayed plot as the current one: replaying redraws it, + # so tinyplot_add() should target it rather than the previous plot. + restore_record_state(attr(x, "tinyplot_state")) + return(invisible(x)) +} + + +#' @rdname recordedtinyplot +#' @param object a `recordedtinyplot` object. +#' @export +str.recordedtinyplot = function(object, ...) { + # The underlying display list str()s to hundreds of lines of dotted pair + # lists, which is noise in an object viewer. Report what is useful instead. + state = attr(object, "tinyplot_state") + cal = state[[".last_call"]] + cat("\n") + if (!is.null(cal)) { + cat(" call: ", paste(deparse(cal), collapse = " "), "\n", sep = "") + } + cat(" display list entries: ", length(object[[1]]), "\n", sep = "") + cat(" size: ", format(utils::object.size(object), units = "auto"), "\n", sep = "") + return(invisible(NULL)) +} diff --git a/R/tinyplot.R b/R/tinyplot.R index 804c78048..d853e31ff 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -479,8 +479,8 @@ #' `width` (above) apply, e.g. will default to `tpar("file.height")` if not #' specified. #' @param record (experimental) a logical value indicating whether the plot -#' should be recorded and returned as a replayable object (see -#' \code{\link[grDevices]{recordPlot}}). Defaults to `FALSE`. Setting to +#' should be recorded and returned as a replayable +#' \code{\link{recordedtinyplot}} object. Defaults to `FALSE`. Setting to #' `TRUE` allows for assignment and later recall, e.g. #' `myplot = tinyplot(...); myplot`. This behaviour can also be set globally #' via `tpar(record = TRUE)`; an explicit argument here takes precedence. @@ -502,8 +502,8 @@ #' #' @returns By default, no return value; called for the side effect of producing #' a plot. If `record = TRUE` (or globally via `tpar(record = TRUE)`), the -#' plot is instead returned invisibly as a `"recordedplot"` object, which can -#' be replayed later; see \code{\link[grDevices]{recordPlot}}. +#' plot is instead returned invisibly as a `"recordedtinyplot"` object, which +#' can be replayed later; see \code{\link{recordedtinyplot}}. #' #' @details #' Disregarding the enhancements that it supports, `tinyplot` tries as far as @@ -1933,8 +1933,8 @@ tinyplot.default = function( } if (isTRUE(record)) { - rec = recordPlot() - # A device that is not recording still yields a well-formed "recordedplot", + rec = as_recordedtinyplot(recordPlot()) + # A device that is not recording still yields a well-formed recording, # just an empty one that replays blank. Say so rather than handing back # something that silently does nothing. if (length(rec[[1]]) == 0L) { diff --git a/R/tpar.R b/R/tpar.R index 30398fa66..7fffcbfc9 100644 --- a/R/tpar.R +++ b/R/tpar.R @@ -87,7 +87,7 @@ #' * `lwd.xaxs`, `lwd.yaxs`: Line widths for the x- and y-axis lines, respectively. Both default to `NULL`, whereby the shared `lwd.axis` value is used instead. #' * `palette.qualitative`: Palette for qualitative colors. See the `palette` argument in `?tinyplot`. #' * `palette.sequential`: Palette for sequential colors. See the `palette` argument in `?tinyplot`. -#' * `record`: (experimental) Logical indicating whether `tinyplot()` should record plots and return them as replayable objects (see \code{\link[grDevices]{recordPlot}}). Defaults to `NULL`, which is equivalent to `FALSE`. Setting to `TRUE` allows for assignment and later recall, e.g. `myplot = tinyplot(...); myplot`. Sets the default for the `record` argument of [`tinyplot()`], which takes precedence. Note that recording requires a device with an enabled display list (see \code{\link[grDevices]{dev.control}}). Most interactive devices enable this behaviour by default, whereas file-based devices do not. However `tinyplot()` automatically enables it for any device that it opens itself via `file`, and further emits a warning if the current device is not recording. +#' * `record`: (experimental) Logical indicating whether `tinyplot()` should record plots and return them as replayable \code{\link{recordedtinyplot}} objects. Defaults to `NULL`, which is equivalent to `FALSE`. Setting to `TRUE` allows for assignment and later recall, e.g. `myplot = tinyplot(...); myplot`. Sets the default for the `record` argument of [`tinyplot()`], which takes precedence. Note that recording requires a device with an enabled display list (see \code{\link[grDevices]{dev.control}}). Most interactive devices enable this behaviour by default, whereas file-based devices do not. However `tinyplot()` automatically enables it for any device that it opens itself via `file`, and further emits a warning if the current device is not recording. #' * `ribbon.alpha`: Numeric factor in the range `[0,1]` for modifying the opacity alpha of "ribbon" and "area" type plots. Default value is `0.2`. #' * `xaxr`, `yaxr`: Numeric giving the rotation of the x- and y-axis tick labels, in degrees counter-clockwise; `NULL` (the default) leaves them unrotated. Unlike `las`, which is limited to the four right angles, any angle is permitted. Setting one overrides `las` for that axis alone, leaving the other axis under `las` as usual, and `0` (or any multiple of 360) counts as no rotation at all. Sets the default for the `xaxr` and `yaxr` arguments of [`tinyplot()`], which take precedence. Two caveats follow from tinyplot drawing rotated labels itself rather than deferring to base `axis()`. First, margins are only resized to fit them under a theme with `dynmar = TRUE` (see `tinytheme`); under the default theme the margins are left alone, so a long rotated label will be clipped unless you widen `mar` yourself. Second, rotated labels do not inherit the thinning that `axis()` applies via `gap.axis`, so they start to overlap once the spacing between ticks falls below `line height / sin(srt)`. #' diff --git a/inst/tinytest/test-record.R b/inst/tinytest/test-record.R index 60403a41b..7aa789e7b 100644 --- a/inst/tinytest/test-record.R +++ b/inst/tinytest/test-record.R @@ -19,10 +19,20 @@ nonrecording_device = function() { recording_device() expect_null(tinyplot(Sepal.Length ~ Petal.Length | Species, data = iris)) -# record = TRUE returns a replayable object with a non-empty display list +# record = TRUE returns a replayable object with a non-empty display list. +# It is wrapped in our own class, but still inherits from "recordedplot" so +# that replayPlot() and friends keep working on it. p = tinyplot(Sepal.Length ~ Petal.Length | Species, data = iris, record = TRUE) +expect_inherits(p, "recordedtinyplot") expect_inherits(p, "recordedplot") expect_true(length(p[[1]]) > 0L) +expect_silent(replayPlot(p)) + +# the wrapper carries the plot context, and str() reports it compactly rather +# than dumping the display list +expect_true(is.list(attr(p, "tinyplot_state"))) +expect_inherits(attr(p, "tinyplot_state")[[".last_call"]], "call") +expect_true(length(capture.output(str(p))) < 10L) # tpar() sets the default, and an explicit argument takes precedence over it tpar(record = TRUE) @@ -95,4 +105,33 @@ if (requireNamespace("svglite", quietly = TRUE)) { unlink(c(f_native, f_replay)) } +# The point of the wrapper: replaying restores plot context, so a subsequent +# tinyplot_add() layers onto the replayed plot rather than whichever plot was +# drawn most recently. +if (requireNamespace("svglite", quietly = TRUE)) { + f_ref = tempfile(fileext = ".svg") + f_test = tempfile(fileext = ".svg") + + svglite::svglite(f_ref, width = 7, height = 7) + tinyplot(dist ~ speed, data = cars) + tinyplot_add(type = "lm") + dev.off() + + svglite::svglite(tempfile(fileext = ".svg"), width = 7, height = 7) + dev.control(displaylist = "enable") + rec = tinyplot(dist ~ speed, data = cars, record = TRUE) + tinyplot(1:10) # intervening plot: without the wrapper, add() would target this + dev.off() + + svglite::svglite(f_test, width = 7, height = 7) + print(rec) + tinyplot_add(type = "lm") + dev.off() + + ref = readLines(f_ref, warn = FALSE) + expect_true(length(ref) > 10L) + expect_identical(readLines(f_test, warn = FALSE), ref) + unlink(c(f_ref, f_test)) +} + tpar(record = NULL) diff --git a/man/recordedtinyplot.Rd b/man/recordedtinyplot.Rd new file mode 100644 index 000000000..96e211bfb --- /dev/null +++ b/man/recordedtinyplot.Rd @@ -0,0 +1,59 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/record.R +\name{recordedtinyplot} +\alias{recordedtinyplot} +\alias{print.recordedtinyplot} +\alias{str.recordedtinyplot} +\title{Recorded tinyplot objects} +\usage{ +\method{print}{recordedtinyplot}(x, ...) + +\method{str}{recordedtinyplot}(object, ...) +} +\arguments{ +\item{x}{a \code{recordedtinyplot} object, returned by a +\code{tinyplot(..., record = TRUE)} call.} + +\item{...}{further arguments passed to \code{\link[grDevices]{replayPlot}}.} + +\item{object}{a \code{recordedtinyplot} object.} +} +\value{ +\code{print()} replays the plot and returns \code{x} invisibly. +} +\description{ +Objects of class \code{recordedtinyplot} are returned by +\code{tinyplot(..., record = TRUE)}. They are a thin wrapper around +\code{\link[grDevices]{recordPlot}}---thus conferring the same replay +functionality via \code{\link[grDevices]{replayPlot}}---but with the +initializing plot call and state added, so that recorded (tiny)plots play +nicely with \code{\link{tinyplot_add}()} and friends. +} +\details{ +Recording requires a device whose display list is enabled (see +\code{\link[grDevices]{dev.control}}); without one there is nothing to +record and the returned plot will replay blank. Interactive devices---the +plot pane of an IDE, say---typically enable it by default, whereas +file-based devices (\code{png}, \code{pdf}, \code{svg}, ...) do not. \code{tinyplot()} enables +it for any device that it opens itself via the \code{file} argument, and warns +if the current device is not recording. + +This class is experimental, as is the \code{record} argument that produces it. +} +\examples{ +\dontrun{ +p = tinyplot(Sepal.Length ~ Petal.Length | Species, data = iris, + record = TRUE) +tinyplot(1:10) # some other plot +p # replay, restoring plot context +tinyplot_add(type = "lm") # layers onto p, not onto the 1:10 plot +} + +} +\seealso{ +\code{\link[grDevices]{recordPlot}} and +\code{\link[grDevices]{replayPlot}}, which this class wraps and defers to +for the actual recording and replaying. +\code{\link[grDevices]{dev.control}} for enabling a device's display list, +without which there is nothing to record. +} diff --git a/man/tinyplot.Rd b/man/tinyplot.Rd index 546db41e6..cf1e12c18 100644 --- a/man/tinyplot.Rd +++ b/man/tinyplot.Rd @@ -653,8 +653,8 @@ graphics windows.} specified.} \item{record}{(experimental) a logical value indicating whether the plot -should be recorded and returned as a replayable object (see -\code{\link[grDevices]{recordPlot}}). Defaults to \code{FALSE}. Setting to +should be recorded and returned as a replayable +\code{\link{recordedtinyplot}} object. Defaults to \code{FALSE}. Setting to \code{TRUE} allows for assignment and later recall, e.g. \verb{myplot = tinyplot(...); myplot}. This behaviour can also be set globally via \code{tpar(record = TRUE)}; an explicit argument here takes precedence. @@ -688,8 +688,8 @@ when extracting the data from \code{formula} and \code{data}.} \value{ By default, no return value; called for the side effect of producing a plot. If \code{record = TRUE} (or globally via \code{tpar(record = TRUE)}), the -plot is instead returned invisibly as a \code{"recordedplot"} object, which can -be replayed later; see \code{\link[grDevices]{recordPlot}}. +plot is instead returned invisibly as a \code{"recordedtinyplot"} object, which +can be replayed later; see \code{\link{recordedtinyplot}}. } \description{ Enhances the base \code{\link[graphics]{plot}} function. Supported features diff --git a/man/tinyplot.data.frame.Rd b/man/tinyplot.data.frame.Rd index f16483ae6..93bda3e0d 100644 --- a/man/tinyplot.data.frame.Rd +++ b/man/tinyplot.data.frame.Rd @@ -39,8 +39,8 @@ to disambiguate from \code{frame.plot}.} \value{ By default, no return value; called for the side effect of producing a plot. If \code{record = TRUE} (or globally via \code{tpar(record = TRUE)}), the -plot is instead returned invisibly as a \code{"recordedplot"} object, which can -be replayed later; see \code{\link[grDevices]{recordPlot}}. +plot is instead returned invisibly as a \code{"recordedtinyplot"} object, which +can be replayed later; see \code{\link{recordedtinyplot}}. } \description{ Convenience interface for visualizing diff --git a/man/tinyplot.matrix.Rd b/man/tinyplot.matrix.Rd index f90ddef17..c20c91063 100644 --- a/man/tinyplot.matrix.Rd +++ b/man/tinyplot.matrix.Rd @@ -38,8 +38,8 @@ titles default to \code{NA}, since the dimnames label both axes.} \value{ By default, no return value; called for the side effect of producing a plot. If \code{record = TRUE} (or globally via \code{tpar(record = TRUE)}), the -plot is instead returned invisibly as a \code{"recordedplot"} object, which can -be replayed later; see \code{\link[grDevices]{recordPlot}}. +plot is instead returned invisibly as a \code{"recordedtinyplot"} object, which +can be replayed later; see \code{\link{recordedtinyplot}}. } \description{ Convenience interface for visualizing diff --git a/man/tinyplot.ts.Rd b/man/tinyplot.ts.Rd index cbc93e53b..63d308a9d 100644 --- a/man/tinyplot.ts.Rd +++ b/man/tinyplot.ts.Rd @@ -27,8 +27,8 @@ multivariate series.} \value{ By default, no return value; called for the side effect of producing a plot. If \code{record = TRUE} (or globally via \code{tpar(record = TRUE)}), the -plot is instead returned invisibly as a \code{"recordedplot"} object, which can -be replayed later; see \code{\link[grDevices]{recordPlot}}. +plot is instead returned invisibly as a \code{"recordedtinyplot"} object, which +can be replayed later; see \code{\link{recordedtinyplot}}. } \description{ Convenience interface for visualizing \code{\link[stats]{ts}} diff --git a/man/tinyplot_add.Rd b/man/tinyplot_add.Rd index 0735b6fa2..cd5244ea0 100644 --- a/man/tinyplot_add.Rd +++ b/man/tinyplot_add.Rd @@ -20,8 +20,8 @@ so those that rely on non-standard evaluation against \code{data}---e.g. \value{ By default, no return value; called for the side effect of producing a plot. If \code{record = TRUE} (or globally via \code{tpar(record = TRUE)}), the -plot is instead returned invisibly as a \code{"recordedplot"} object, which can -be replayed later; see \code{\link[grDevices]{recordPlot}}. +plot is instead returned invisibly as a \code{"recordedtinyplot"} object, which +can be replayed later; see \code{\link{recordedtinyplot}}. } \description{ This convenience function grabs the preceding \code{tinyplot} call and updates it diff --git a/man/tpar.Rd b/man/tpar.Rd index 01c6a1ee5..680290ba6 100644 --- a/man/tpar.Rd +++ b/man/tpar.Rd @@ -98,7 +98,7 @@ you should rather use \code{par()} instead. \item \code{lwd.xaxs}, \code{lwd.yaxs}: Line widths for the x- and y-axis lines, respectively. Both default to \code{NULL}, whereby the shared \code{lwd.axis} value is used instead. \item \code{palette.qualitative}: Palette for qualitative colors. See the \code{palette} argument in \code{?tinyplot}. \item \code{palette.sequential}: Palette for sequential colors. See the \code{palette} argument in \code{?tinyplot}. -\item \code{record}: (experimental) Logical indicating whether \code{tinyplot()} should record plots and return them as replayable objects (see \code{\link[grDevices]{recordPlot}}). Defaults to \code{NULL}, which is equivalent to \code{FALSE}. Setting to \code{TRUE} allows for assignment and later recall, e.g. \verb{myplot = tinyplot(...); myplot}. Sets the default for the \code{record} argument of \code{\link[=tinyplot]{tinyplot()}}, which takes precedence. Note that recording requires a device with an enabled display list (see \code{\link[grDevices]{dev.control}}). Most interactive devices enable this behaviour by default, whereas file-based devices do not. However \code{tinyplot()} automatically enables it for any device that it opens itself via \code{file}, and further emits a warning if the current device is not recording. +\item \code{record}: (experimental) Logical indicating whether \code{tinyplot()} should record plots and return them as replayable \code{\link{recordedtinyplot}} objects. Defaults to \code{NULL}, which is equivalent to \code{FALSE}. Setting to \code{TRUE} allows for assignment and later recall, e.g. \verb{myplot = tinyplot(...); myplot}. Sets the default for the \code{record} argument of \code{\link[=tinyplot]{tinyplot()}}, which takes precedence. Note that recording requires a device with an enabled display list (see \code{\link[grDevices]{dev.control}}). Most interactive devices enable this behaviour by default, whereas file-based devices do not. However \code{tinyplot()} automatically enables it for any device that it opens itself via \code{file}, and further emits a warning if the current device is not recording. \item \code{ribbon.alpha}: Numeric factor in the range \verb{[0,1]} for modifying the opacity alpha of "ribbon" and "area" type plots. Default value is \code{0.2}. \item \code{xaxr}, \code{yaxr}: Numeric giving the rotation of the x- and y-axis tick labels, in degrees counter-clockwise; \code{NULL} (the default) leaves them unrotated. Unlike \code{las}, which is limited to the four right angles, any angle is permitted. Setting one overrides \code{las} for that axis alone, leaving the other axis under \code{las} as usual, and \code{0} (or any multiple of 360) counts as no rotation at all. Sets the default for the \code{xaxr} and \code{yaxr} arguments of \code{\link[=tinyplot]{tinyplot()}}, which take precedence. Two caveats follow from tinyplot drawing rotated labels itself rather than deferring to base \code{axis()}. First, margins are only resized to fit them under a theme with \code{dynmar = TRUE} (see \code{tinytheme}); under the default theme the margins are left alone, so a long rotated label will be clipped unless you widen \code{mar} yourself. Second, rotated labels do not inherit the thinning that \code{axis()} applies via \code{gap.axis}, so they start to overlap once the spacing between ticks falls below \verb{line height / sin(srt)}. } From 5674ab24f66758eefb9b9dc8d00f10d2203c8e20 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Thu, 10 Sep 2026 18:13:03 -0700 Subject: [PATCH 11/18] website --- altdoc/pkgdown.yml | 2 +- altdoc/quarto_website.yml | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/altdoc/pkgdown.yml b/altdoc/pkgdown.yml index adefe681b..d8c0866ea 100644 --- a/altdoc/pkgdown.yml +++ b/altdoc/pkgdown.yml @@ -2,7 +2,7 @@ altdoc: 0.7.3 pandoc: 3.10.2 pkgdown: 2.1.3 pkgdown_sha: ~ -last_built: 2026-09-05T20:50:52+0000 +last_built: 2026-09-11T01:09:05+0000 urls: reference: https://grantmcdermott.com/tinyplot/man article: https://grantmcdermott.com/tinyplot/vignettes diff --git a/altdoc/quarto_website.yml b/altdoc/quarto_website.yml index ffaae09b9..ccae17557 100644 --- a/altdoc/quarto_website.yml +++ b/altdoc/quarto_website.yml @@ -154,6 +154,8 @@ website: file: man/draw_legend.qmd - text: get_saved_par file: man/get_saved_par.qmd + - text: recordedtinyplot + file: man/recordedtinyplot.qmd # - text: tinyAxis # file: man/tinyAxis.qmd - text: tinylabel From 6853f1f36ddc46e90d1ee7e7ed14fa2c54fb16cf Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Thu, 10 Sep 2026 20:59:45 -0700 Subject: [PATCH 12/18] clean-up --- R/record.R | 43 +++++++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/R/record.R b/R/record.R index 00eff3ee1..f3b63e906 100644 --- a/R/record.R +++ b/R/record.R @@ -11,22 +11,26 @@ ## along and put it back when the plot is replayed. -# Plot-scoped entries of .tinyplot_env: everything tinyplot_add() and the -# layering machinery need in order to treat a replayed plot as "the current -# plot". Deliberately excludes package-level config that is not tied to any -# single plot: .base_par_names (a device cache), .registered_themes and -# .tpar_hooks (user/session settings), and .saved_par_first (the session's -# baseline par, which a replay has no business overwriting). +# Plot-scoped entries of .tinyplot_env that tinyplot_add() and the layering +# machinery need in order to treat a replayed plot as "the current plot". +# +# Two kinds of entry are deliberately absent. First, package-level config that +# is not tied to any single plot: .base_par_names (a device cache), +# .registered_themes and .tpar_hooks (session settings). Second -- and less +# obviously -- the saved par/usr/dev state (.saved_par_before, .saved_par_after, +# usr_orig, dev_orig). That state describes the device the plot was *recorded* +# on, which need not be the device it is replayed onto: recording a plot with +# `file = ` captures a file device that is then closed, so restoring its par on +# replay leaves the live device inconsistent and a following tinyplot_add() +# fails with "plot.new has not been called yet". Replaying redraws the plot on +# the current device anyway, so that device's own par is the correct one to +# keep. recorded_state_keys = c( ".last_call", - ".saved_par_before", - ".saved_par_after", ".group_offsets", ".offsets_axis", ".facet_labs", ".top_legend_soma", - "usr_orig", - "dev_orig", "xlabs_orig" ) @@ -35,15 +39,18 @@ recorded_state_keys = c( capture_record_state = function() { out = lapply(recorded_state_keys, function(k) .tinyplot_env[[k]]) names(out) = recorded_state_keys - # Drop `record` from the stored call. tinyplot_add() rebuilds the last call, - # so leaving it in would make every layer added after a replay record itself - # too -- and warn if that device happens not to be recording. Recording - # describes how one call returned its value, not a property of the plot to be - # inherited; pass `record = TRUE` to tinyplot_add() explicitly to record a - # layered plot. + # Drop the per-call output directives from the stored call. tinyplot_add() + # rebuilds the last call, so leaving these in would make every layer added + # after a replay repeat them: `record` would re-record each layer (and warn + # if that device was not recording), while `file`/`width`/`height` would open + # a fresh device via setup_device() and then fail, because add-mode draws + # onto a plot that the new device does not have. These arguments describe how + # one call produced its output, not properties of the plot to be inherited; + # pass them to tinyplot_add() explicitly if a layer needs them. cal = out[[".last_call"]] - if (is.call(cal) && "record" %in% names(as.list(cal))) { - cal[["record"]] = NULL + if (is.call(cal)) { + drop = intersect(c("record", "file", "width", "height"), names(as.list(cal))) + for (nm in drop) cal[[nm]] = NULL out[[".last_call"]] = cal } return(out) From 4268124d0df1e5046c4f81737396d74897009edd Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Thu, 10 Sep 2026 20:59:56 -0700 Subject: [PATCH 13/18] intro vignette --- altdoc/pkgdown.yml | 2 +- vignettes/introduction.qmd | 46 +++++++++++++++++++++++++++++++++----- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/altdoc/pkgdown.yml b/altdoc/pkgdown.yml index d8c0866ea..de35b9663 100644 --- a/altdoc/pkgdown.yml +++ b/altdoc/pkgdown.yml @@ -2,7 +2,7 @@ altdoc: 0.7.3 pandoc: 3.10.2 pkgdown: 2.1.3 pkgdown_sha: ~ -last_built: 2026-09-11T01:09:05+0000 +last_built: 2026-09-11T03:06:01+0000 urls: reference: https://grantmcdermott.com/tinyplot/man article: https://grantmcdermott.com/tinyplot/vignettes diff --git a/vignettes/introduction.qmd b/vignettes/introduction.qmd index 059f1625b..5cc42123e 100644 --- a/vignettes/introduction.qmd +++ b/vignettes/introduction.qmd @@ -555,25 +555,31 @@ tinyplot( ) ``` -## Saving plots + +```{r theme_again} +#| include: false +tinytheme("clean2") +``` + +## Save and replay plots A final point to note is that **tinyplot** offers convenience features for -exporting plots to disk. Simply invoke the `file` argument to specify the -relevant file path (including the extension type). You can customize the output -dimensions (in inches) via the accompanying `width` and `height` +saving (and replaying) plots. For example, you can export a plot to disk simply +by invoking the `file` argument and specifying the relevant file path (including +the extension type). You can also customize the output +dimensions (in inches) via the companion `width` and `height` arguments.^[The default dimensions are 7x7, with a resolution of 300 DPI. However, these too can be customized via the `file.width`, `file.height`, and `file.res` parameters in [`tpar()`](https://grantmcdermott.com/tinyplot/man/tpar.html).] ```{r save_plot} -#| eval: false tinyplot( Temp ~ Day | Month, data = aq, file = "aq.png", width = 8, height = 5 ) -# optional: delete the saved plot +# optional: delete the saved png unlink("aq.png") ``` @@ -583,6 +589,34 @@ the traditional approach of manually opening an external graphics device, e.g. over to the exported file. Feel free to try yourself by setting some global graphics parameters via `tpar()` and then using `file` to save a plot. +A related---albeit experimental---feature is the ability to _record_ +plots, keeping them in your session rather than writing them to disk. Pass +`record = TRUE` and **tinyplot** hands back the plot as an object that you can +assign, hold onto, and recall whenever you like. As per the example below, you +can even record a plot that you write to disk and then retrieve it to your +interactive session later on. + +```{r record_plot} +p = tinyplot( + Temp ~ Day | Month, data = aq, + file = "aq2.png", + record = TRUE # <-- record plot for later recall +) +unlink("aq2.png") # optional: delete the saved png + +# + +p # replay initial plot (this time in our interactive window) +plt_add(type = "lm") # add another layer onto it +``` + +As noted above, recording and replaying plots is still something of an +experimental feature, introduced in **tinyplot** v0.8.0. We've made it opt-in +via an explicit argument for now, although you can also enable it globally for +your session via `tpar(record = TRUE)`. One caveat is that recording and +assigning detailed plots with many elements can eventually take up quite a bit +of memory. + ## Reset theme Don't forget to reset the plot theme. From bacfb99bc082e97a131d722e83f7476e8aaa38b7 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Thu, 10 Sep 2026 21:00:04 -0700 Subject: [PATCH 14/18] news --- NEWS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/NEWS.md b/NEWS.md index 4f4c984f0..c57d083a9 100644 --- a/NEWS.md +++ b/NEWS.md @@ -147,8 +147,8 @@ related to plot layering. See "Bug fixes" below. too. (#717 @grantmcdermott) - (Experimental) `record` enables recording plots as replayable objects, closing another long-standing feature request (#121). Specifically, setting - `record = TRUE` returns a `"recordedplot"` object (see - `?grDevices::recordPlot`), allowing for assignment and later recall, e.g. + `record = TRUE` returns a `"recordedtinyplot"` object (see + `?recordedtinyplot`), allowing for assignment and later recall, e.g. `myplot = tinyplot(...); myplot`. Also settable globally via `tpar(record = TRUE)`, so that all `tinyplot()` plots are automatically recorded---with potential memory implications for detailed plots with _many_ From c85ee6396f293ddd2725bba0d6fba796ff99c0af Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Thu, 10 Sep 2026 21:21:31 -0700 Subject: [PATCH 15/18] copilot comments --- R/record.R | 37 +++++++++++++++++++++++++++++++------ R/tinyplot.R | 2 +- R/tinyplot.data.frame.R | 7 ++++++- inst/tinytest/test-record.R | 29 ++++++++++++++++++----------- man/recordedtinyplot.Rd | 13 ++++++++++--- man/tinyplot.data.frame.Rd | 6 ++++-- 6 files changed, 70 insertions(+), 24 deletions(-) diff --git a/R/record.R b/R/record.R index f3b63e906..f7eb00ed8 100644 --- a/R/record.R +++ b/R/record.R @@ -71,8 +71,11 @@ restore_record_state = function(state) { # Wrap a recordedplot, stashing the state alongside it. The result still # inherits from "recordedplot", so replayPlot() and everything else that # expects a bare recording keep working. -as_recordedtinyplot = function(rec) { - attr(rec, "tinyplot_state") = capture_record_state() +as_recordedtinyplot = function(rec, flip = FALSE) { + state = capture_record_state() + # Needed to recompute usr_orig on replay, below. + state[["flip"]] = isTRUE(flip) + attr(rec, "tinyplot_state") = state class(rec) = c("recordedtinyplot", "recordedplot") return(rec) } @@ -83,9 +86,9 @@ as_recordedtinyplot = function(rec) { #' @description Objects of class `recordedtinyplot` are returned by #' `tinyplot(..., record = TRUE)`. They are a thin wrapper around #' \code{\link[grDevices]{recordPlot}}---thus conferring the same replay -#' functionality via \code{\link[grDevices]{replayPlot}}---but with the -#' initializing plot call and state added, so that recorded (tiny)plots play -#' nicely with \code{\link{tinyplot_add}()} and friends. +#' functionality---but with the initializing plot call and state added, so that +#' recorded (tiny)plots play nicely with \code{\link{tinyplot_add}()} and +#' friends. #' #' @param x a `recordedtinyplot` object, returned by a #' `tinyplot(..., record = TRUE)` call. @@ -101,6 +104,13 @@ as_recordedtinyplot = function(rec) { #' it for any device that it opens itself via the `file` argument, and warns #' if the current device is not recording. #' +#' Printing a recorded plot---either explicitly with `print()`, or simply by +#' evaluating it at the console---is what restores the initializing call, so +#' that a subsequent \code{\link{tinyplot_add}()} layers onto it. Calling +#' \code{\link[grDevices]{replayPlot}()} on the object instead redraws it +#' just as it would any other recording, but leaves the current plot context +#' untouched; layering after that route targets whichever plot was drawn last. +#' #' This class is experimental, as is the `record` argument that produces it. #' #' @seealso \code{\link[grDevices]{recordPlot}} and @@ -130,7 +140,22 @@ print.recordedtinyplot = function(x, ...) { replayPlot(rec, ...) # Only now adopt the replayed plot as the current one: replaying redraws it, # so tinyplot_add() should target it rather than the previous plot. - restore_record_state(attr(x, "tinyplot_state")) + state = attr(x, "tinyplot_state") + restore_record_state(state) + # align_layer() validates that a layer is being added to the plot it thinks + # is current, by comparing usr_orig/dev_orig against the live device. Those + # describe whichever plot was drawn last, which after a replay is the + # *intervening* plot, so the comparison fails and categorical layers silently + # skip alignment. Recompute them from the plot we just drew. Note we derive + # these from the live device rather than restoring the recorded values: the + # recording may come from a device that no longer exists (`file = `), and + # restoring its par leaves the current device inconsistent. + .tinyplot_env[["dev_orig"]] = dev.cur() + .tinyplot_env[["usr_orig"]] = if (isTRUE(state[["flip"]])) { + par("usr")[c(3, 4, 1, 2)] + } else { + par("usr") + } return(invisible(x)) } diff --git a/R/tinyplot.R b/R/tinyplot.R index d853e31ff..80d56b7cf 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -1933,7 +1933,7 @@ tinyplot.default = function( } if (isTRUE(record)) { - rec = as_recordedtinyplot(recordPlot()) + rec = as_recordedtinyplot(recordPlot(), flip = isTRUE(settings$flip)) # A device that is not recording still yields a well-formed recording, # just an empty one that replays blank. Say so rather than handing back # something that silently does nothing. diff --git a/R/tinyplot.data.frame.R b/R/tinyplot.data.frame.R index 6cfc04d41..1a26d0ad2 100644 --- a/R/tinyplot.data.frame.R +++ b/R/tinyplot.data.frame.R @@ -37,7 +37,12 @@ #' to disambiguate from `frame.plot`. #' @param ... further arguments passed to `tinyplot`. #' -#' @inherit tinyplot return +#' @returns By default, no return value; called for the side effect of producing +#' a plot. If `record = TRUE` (or globally via `tpar(record = TRUE)`), the +#' plot is instead returned invisibly as a `"recordedtinyplot"` object; see +#' \code{\link{recordedtinyplot}}. The exception is the pairs-style case +#' (more than two columns), which draws a grid of sub-plots and always +#' returns `NULL`. #' #' @examples #' ## using tinyplot() with data frames diff --git a/inst/tinytest/test-record.R b/inst/tinytest/test-record.R index 7aa789e7b..cc9df675a 100644 --- a/inst/tinytest/test-record.R +++ b/inst/tinytest/test-record.R @@ -105,33 +105,40 @@ if (requireNamespace("svglite", quietly = TRUE)) { unlink(c(f_native, f_replay)) } -# The point of the wrapper: replaying restores plot context, so a subsequent +# The point of the wrapper: replaying restores plot context, so a following # tinyplot_add() layers onto the replayed plot rather than whichever plot was -# drawn most recently. +# drawn most recently. Reordered categories make this a strict test -- it also +# covers align_layer(), which validates against usr_orig/dev_orig and so needs +# those refreshed on replay rather than left describing the intervening plot. if (requireNamespace("svglite", quietly = TRUE)) { + d1 = data.frame(g = factor(c("a", "b", "c")), y = c(1, 2, 3)) + d2 = data.frame( + g = factor(c("a", "b", "c"), levels = c("c", "b", "a")), + y = c(1.5, 2.5, 3.5) + ) f_ref = tempfile(fileext = ".svg") - f_test = tempfile(fileext = ".svg") + f_rep = tempfile(fileext = ".svg") svglite::svglite(f_ref, width = 7, height = 7) - tinyplot(dist ~ speed, data = cars) - tinyplot_add(type = "lm") + tinyplot(y ~ g, data = d1) + tinyplot_add(y ~ g, data = d2, col = "red") dev.off() svglite::svglite(tempfile(fileext = ".svg"), width = 7, height = 7) dev.control(displaylist = "enable") - rec = tinyplot(dist ~ speed, data = cars, record = TRUE) - tinyplot(1:10) # intervening plot: without the wrapper, add() would target this + rec = tinyplot(y ~ g, data = d1, record = TRUE) + tinyplot(1:10) # intervening plot: leaves usr_orig describing *this* plot dev.off() - svglite::svglite(f_test, width = 7, height = 7) + svglite::svglite(f_rep, width = 7, height = 7) print(rec) - tinyplot_add(type = "lm") + tinyplot_add(y ~ g, data = d2, col = "red") dev.off() ref = readLines(f_ref, warn = FALSE) expect_true(length(ref) > 10L) - expect_identical(readLines(f_test, warn = FALSE), ref) - unlink(c(f_ref, f_test)) + expect_identical(readLines(f_rep, warn = FALSE), ref) + unlink(c(f_ref, f_rep)) } tpar(record = NULL) diff --git a/man/recordedtinyplot.Rd b/man/recordedtinyplot.Rd index 96e211bfb..287d3d21a 100644 --- a/man/recordedtinyplot.Rd +++ b/man/recordedtinyplot.Rd @@ -25,9 +25,9 @@ Objects of class \code{recordedtinyplot} are returned by \code{tinyplot(..., record = TRUE)}. They are a thin wrapper around \code{\link[grDevices]{recordPlot}}---thus conferring the same replay -functionality via \code{\link[grDevices]{replayPlot}}---but with the -initializing plot call and state added, so that recorded (tiny)plots play -nicely with \code{\link{tinyplot_add}()} and friends. +functionality---but with the initializing plot call and state added, so that +recorded (tiny)plots play nicely with \code{\link{tinyplot_add}()} and +friends. } \details{ Recording requires a device whose display list is enabled (see @@ -38,6 +38,13 @@ file-based devices (\code{png}, \code{pdf}, \code{svg}, ...) do not. \code{tinyp it for any device that it opens itself via the \code{file} argument, and warns if the current device is not recording. +Printing a recorded plot---either explicitly with \code{print()}, or simply by +evaluating it at the console---is what restores the initializing call, so +that a subsequent \code{\link{tinyplot_add}()} layers onto it. Calling +\code{\link[grDevices]{replayPlot}()} on the object instead redraws it +just as it would any other recording, but leaves the current plot context +untouched; layering after that route targets whichever plot was drawn last. + This class is experimental, as is the \code{record} argument that produces it. } \examples{ diff --git a/man/tinyplot.data.frame.Rd b/man/tinyplot.data.frame.Rd index 93bda3e0d..f2d3eaddb 100644 --- a/man/tinyplot.data.frame.Rd +++ b/man/tinyplot.data.frame.Rd @@ -39,8 +39,10 @@ to disambiguate from \code{frame.plot}.} \value{ By default, no return value; called for the side effect of producing a plot. If \code{record = TRUE} (or globally via \code{tpar(record = TRUE)}), the -plot is instead returned invisibly as a \code{"recordedtinyplot"} object, which -can be replayed later; see \code{\link{recordedtinyplot}}. +plot is instead returned invisibly as a \code{"recordedtinyplot"} object; see +\code{\link{recordedtinyplot}}. The exception is the pairs-style case +(more than two columns), which draws a grid of sub-plots and always +returns \code{NULL}. } \description{ Convenience interface for visualizing From b454e34d64e479111fe369af20871399b42e222f Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Fri, 11 Sep 2026 07:27:36 -0700 Subject: [PATCH 16/18] fix theme record clobber gotcha --- R/tpar.R | 13 +++++++++++++ inst/tinytest/test-record.R | 16 ++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/R/tpar.R b/R/tpar.R index 7fffcbfc9..83685471f 100644 --- a/R/tpar.R +++ b/R/tpar.R @@ -399,6 +399,10 @@ assert_tpar = function(.tpar) { } init_tpar = function(rm_hook = FALSE) { + # `record` changes what tinyplot() returns, not how the plot looks, so it + # survives the wipe below (tinytheme() calls init_tpar() on every switch). + record_old = .tpar[["record"]] + rm(list = names(.tpar), envir = .tpar) if (isTRUE(rm_hook)) { @@ -419,6 +423,15 @@ init_tpar = function(rm_hook = FALSE) { .tpar$file.height = if (is.null(getOption("tinyplot_file.height"))) 7 else as.numeric(getOption("tinyplot_file.height")) .tpar$file.res = if (is.null(getOption("tinyplot_file.res"))) 300 else as.numeric(getOption("tinyplot_file.res")) + # Record plots as replayable objects (see `?recordedtinyplot`) + .tpar$record = if (!is.null(record_old)) { + record_old + } else if (is.null(getOption("tinyplot_record"))) { + NULL + } else { + as.logical(getOption("tinyplot_record")) + } + # Facet margin, i.e. gap between the individual facet windows .tpar$fmar = if (is.null(getOption("tinyplot_fmar"))) c(1, 1, 1, 1) else as.numeric(getOption("tinyplot_fmar")) diff --git a/inst/tinytest/test-record.R b/inst/tinytest/test-record.R index cc9df675a..20c6fb6ea 100644 --- a/inst/tinytest/test-record.R +++ b/inst/tinytest/test-record.R @@ -40,6 +40,22 @@ expect_inherits(tinyplot(Sepal.Length ~ Petal.Length, data = iris), "recordedplo expect_null(tinyplot(Sepal.Length ~ Petal.Length, data = iris, record = FALSE)) tpar(record = NULL) expect_null(tinyplot(Sepal.Length ~ Petal.Length, data = iris)) + +# A theme switch must not clear tpar(record), including the ephemeral themes +# that tinyplot(theme=) applies and then undoes around a single plot. +tpar(record = TRUE) +tinytheme("clean") +expect_true(tpar("record")) +tinytheme() +expect_true(tpar("record")) +expect_inherits( + tinyplot(Sepal.Length ~ Petal.Length, data = iris, theme = "clean"), + "recordedtinyplot" +) +expect_true(tpar("record")) +tinyplot(Sepal.Length ~ Petal.Length, data = iris, theme = "default") +expect_true(tpar("record")) +tpar(record = NULL) dev.off() # A non-recording device yields an empty plot; warn rather than hand back From ca6e092c3622bbdd60ab832fba490bccd3666dbf Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Fri, 11 Sep 2026 07:44:14 -0700 Subject: [PATCH 17/18] accessor consistency while we're at it --- R/tpar.R | 50 +++++++++++++++++++++++++------------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/R/tpar.R b/R/tpar.R index 83685471f..a56ec402d 100644 --- a/R/tpar.R +++ b/R/tpar.R @@ -381,7 +381,7 @@ assert_tpar = function(.tpar) { assert_true(length(facet.col) == 1, name = "length(facet.col)==1") } - facet.bg = .tpar$facet.bg + facet.bg = .tpar[["facet.bg"]] if (!is.null(facet.bg)) { if (!is.numeric(facet.bg) && !is.character(facet.bg)) { stop("facet.bg needs to be NULL, or a numeric or character", call. = FALSE) @@ -389,7 +389,7 @@ assert_tpar = function(.tpar) { assert_true(length(facet.bg) == 1, name = "length(facet.bg)==1") } - facet.border = .tpar$facet.border + facet.border = .tpar[["facet.border"]] if (!is.null(facet.border)) { if (!is.numeric(facet.border) && !is.character(facet.border) && !is.na(facet.border)) { stop("facet.border needs to be NULL, or a numeric, character, or NA", call. = FALSE) @@ -413,18 +413,18 @@ init_tpar = function(rm_hook = FALSE) { } } - .tpar$cairo = if (is.null(getOption("tinyplot_cairo"))) capabilities("cairo") else as.logical(getOption("tinyplot_cairo")) + .tpar[["cairo"]] = if (is.null(getOption("tinyplot_cairo"))) capabilities("cairo") else as.logical(getOption("tinyplot_cairo")) - .tpar$dynmar = if (is.null(getOption("tinyplot_dynmar"))) FALSE else as.logical(getOption("tinyplot_dynmar")) + .tpar[["dynmar"]] = if (is.null(getOption("tinyplot_dynmar"))) FALSE else as.logical(getOption("tinyplot_dynmar")) # Figure output options if written to file - .tpar$file.width = if (is.null(getOption("tinyplot_file.width"))) 7 else as.numeric(getOption("tinyplot_file.width")) - .tpar$file.height = if (is.null(getOption("tinyplot_file.height"))) 7 else as.numeric(getOption("tinyplot_file.height")) - .tpar$file.res = if (is.null(getOption("tinyplot_file.res"))) 300 else as.numeric(getOption("tinyplot_file.res")) + .tpar[["file.width"]] = if (is.null(getOption("tinyplot_file.width"))) 7 else as.numeric(getOption("tinyplot_file.width")) + .tpar[["file.height"]] = if (is.null(getOption("tinyplot_file.height"))) 7 else as.numeric(getOption("tinyplot_file.height")) + .tpar[["file.res"]] = if (is.null(getOption("tinyplot_file.res"))) 300 else as.numeric(getOption("tinyplot_file.res")) # Record plots as replayable objects (see `?recordedtinyplot`) - .tpar$record = if (!is.null(record_old)) { + .tpar[["record"]] = if (!is.null(record_old)) { record_old } else if (is.null(getOption("tinyplot_record"))) { NULL @@ -433,36 +433,36 @@ init_tpar = function(rm_hook = FALSE) { } # Facet margin, i.e. gap between the individual facet windows - .tpar$fmar = if (is.null(getOption("tinyplot_fmar"))) c(1, 1, 1, 1) else as.numeric(getOption("tinyplot_fmar")) + .tpar[["fmar"]] = if (is.null(getOption("tinyplot_fmar"))) c(1, 1, 1, 1) else as.numeric(getOption("tinyplot_fmar")) # Other facet options - .tpar$facet.cex = if (is.null(getOption("tinyplot_facet.cex"))) 1 else as.numeric(getOption("tinyplot_facet.cex")) - .tpar$facet.font = if (is.null(getOption("tinyplot_facet.font"))) NULL else as.numeric(getOption("tinyplot_facet.font")) - .tpar$facet.col = if (is.null(getOption("tinyplot_facet.col"))) NULL else getOption("tinyplot_facet.col") - .tpar$facet.bg = if (is.null(getOption("tinyplot_facet.bg"))) NULL else getOption("tinyplot_facet.bg") - .tpar$facet.border = if (is.null(getOption("tinyplot_facet.border"))) NA else getOption("tinyplot_facet.border") - .tpar$facet.labeller = if (is.null(getOption("tinyplot_facet.labeller"))) NULL else getOption("tinyplot_facet.labeller") - .tpar$facet.prefix = if (is.null(getOption("tinyplot_facet.prefix"))) NULL else getOption("tinyplot_facet.prefix") - .tpar$facet.sep = if (is.null(getOption("tinyplot_facet.sep"))) NULL else getOption("tinyplot_facet.sep") + .tpar[["facet.cex"]] = if (is.null(getOption("tinyplot_facet.cex"))) 1 else as.numeric(getOption("tinyplot_facet.cex")) + .tpar[["facet.font"]] = if (is.null(getOption("tinyplot_facet.font"))) NULL else as.numeric(getOption("tinyplot_facet.font")) + .tpar[["facet.col"]] = if (is.null(getOption("tinyplot_facet.col"))) NULL else getOption("tinyplot_facet.col") + .tpar[["facet.bg"]] = if (is.null(getOption("tinyplot_facet.bg"))) NULL else getOption("tinyplot_facet.bg") + .tpar[["facet.border"]] = if (is.null(getOption("tinyplot_facet.border"))) NA else getOption("tinyplot_facet.border") + .tpar[["facet.labeller"]] = if (is.null(getOption("tinyplot_facet.labeller"))) NULL else getOption("tinyplot_facet.labeller") + .tpar[["facet.prefix"]] = if (is.null(getOption("tinyplot_facet.prefix"))) NULL else getOption("tinyplot_facet.prefix") + .tpar[["facet.sep"]] = if (is.null(getOption("tinyplot_facet.sep"))) NULL else getOption("tinyplot_facet.sep") # Plot grid - .tpar$grid = if (is.null(getOption("tinyplot_grid"))) FALSE else as.logical(getOption("tinyplot_grid")) - .tpar$grid.col = if (is.null(getOption("tinyplot_grid.col"))) "lightgray" else getOption("tinyplot_grid.col") - .tpar$grid.lty = if (is.null(getOption("tinyplot_grid.lty"))) "dotted" else getOption("tinyplot_grid.lty") - .tpar$grid.lwd = if (is.null(getOption("tinyplot_grid.lwd"))) 1 else as.numeric(getOption("tinyplot_grid.lwd")) + .tpar[["grid"]] = if (is.null(getOption("tinyplot_grid"))) FALSE else as.logical(getOption("tinyplot_grid")) + .tpar[["grid.col"]] = if (is.null(getOption("tinyplot_grid.col"))) "lightgray" else getOption("tinyplot_grid.col") + .tpar[["grid.lty"]] = if (is.null(getOption("tinyplot_grid.lty"))) "dotted" else getOption("tinyplot_grid.lty") + .tpar[["grid.lwd"]] = if (is.null(getOption("tinyplot_grid.lwd"))) 1 else as.numeric(getOption("tinyplot_grid.lwd")) # Default colour for single-group displays (NULL defers to the first # qualitative palette colour, or base palette()[1] if no theme is active) - .tpar$col.default = if (is.null(getOption("tinyplot_col.default"))) NULL else getOption("tinyplot_col.default") + .tpar[["col.default"]] = if (is.null(getOption("tinyplot_col.default"))) NULL else getOption("tinyplot_col.default") # Legend justification - .tpar$ljust = if (is.null(getOption("tinyplot_ljust"))) "left" else getOption("tinyplot_ljust") + .tpar[["ljust"]] = if (is.null(getOption("tinyplot_ljust"))) "left" else getOption("tinyplot_ljust") # Legend margin, i.e. gap between the legend and the plot elements - .tpar$lmar = if (is.null(getOption("tinyplot_lmar"))) c(1.0, 0.1) else as.numeric(getOption("tinyplot_lmar")) + .tpar[["lmar"]] = if (is.null(getOption("tinyplot_lmar"))) c(1.0, 0.1) else as.numeric(getOption("tinyplot_lmar")) # Alpha fill (transparency) default for ribbon and area plots - .tpar$ribbon.alpha = if (is.null(getOption("tinyplot_ribbon.alpha"))) 0.2 else as.numeric(getOption("tinyplot_ribbon.alpha")) + .tpar[["ribbon.alpha"]] = if (is.null(getOption("tinyplot_ribbon.alpha"))) 0.2 else as.numeric(getOption("tinyplot_ribbon.alpha")) } ## initialize internal environment for tpar variables From 0b04ba1c301f7bfe3e82386007da4eb1f3bc8a1f Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Fri, 11 Sep 2026 09:34:46 -0700 Subject: [PATCH 18/18] par leak gotcha --- R/record.R | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/R/record.R b/R/record.R index f7eb00ed8..ff9ee372d 100644 --- a/R/record.R +++ b/R/record.R @@ -137,6 +137,12 @@ print.recordedtinyplot = function(x, ...) { rec = x attr(rec, "tinyplot_state") = NULL class(rec) = "recordedplot" + # replayPlot() re-executes the recorded par (a grDevices property, not + # ours), which would leave a themed plot's cosmetic par on the live device + # and leak into later plots. Restored below, once the replay is done. Only + # the keys a theme can set are read: the coordinate system must be left as + # the replay draws it, so that tinyplot_add() can still layer onto it. + pre_par = par(intersect(names(theme_default), base_par_names())) replayPlot(rec, ...) # Only now adopt the replayed plot as the current one: replaying redraws it, # so tinyplot_add() should target it rather than the previous plot. @@ -156,6 +162,8 @@ print.recordedtinyplot = function(x, ...) { } else { par("usr") } + # Undo the leak noted above, mirroring what an ephemeral theme does on exit. + par(pre_par) return(invisible(x)) }