diff --git a/CLAUDE.md b/CLAUDE.md index ea650582..7ab63b91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -214,6 +214,9 @@ Steps: 4. Run `make document` to update NAMESPACE 5. Add tests in `inst/tinytest/test-type_.R` 6. Add snapshot SVGs by running tests on Linux (devcontainer) +7. Add the new page to the website navigation in `altdoc/quarto_website.yml` + +**Important:** Step 7 applies to any new exported function or page, not just plot types. Whenever you add or rename an exported function that gets its own `.Rd` page, add it to `altdoc/quarto_website.yml` under the appropriate section. ### Modifying Legend Behaviour Type-specific legend customizations should go in the type's `data` function by modifying `settings$legend_args`: diff --git a/NAMESPACE b/NAMESPACE index cd450501..605b5173 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -11,6 +11,9 @@ export(tinylabel) export(tinyplot) export(tinyplot_add) export(tinytheme) +export(tinytheme_list) +export(tinytheme_register) +export(tinytheme_unregister) export(tpar) export(type_abline) export(type_area) diff --git a/NEWS.md b/NEWS.md index 101d01b3..2c3ae34d 100644 --- a/NEWS.md +++ b/NEWS.md @@ -74,6 +74,12 @@ New theme features: - `"nber"` (NBER working paper style) - `"socviz"` (based on Kieran Healy's [book](https://socviz.co/)) - `"web"` (web publication, e.g. FiveThirtyEight) +- New `tinytheme_register()` function for registering custom user themes. + Registered themes inherit from any built-in (or previously registered) theme, + apply user-specified overrides, and can then be used by name with + `tinytheme()` or `tinyplot(..., theme = )`. Companion functions + `tinytheme_list()` and `tinytheme_unregister()` further support this + functionality. (#608 @grantmcdermott) Theme fixes: diff --git a/R/environment.R b/R/environment.R index e147caa3..f6d66482 100644 --- a/R/environment.R +++ b/R/environment.R @@ -35,5 +35,6 @@ set_environment_variable( .saved_par_after = NULL, .saved_par_first = NULL, .last_call = NULL, - .tpar_hooks = NULL + .tpar_hooks = NULL, + .registered_themes = NULL ) diff --git a/R/tinyplot.R b/R/tinyplot.R index e6a936cf..988bfd0e 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -1028,9 +1028,8 @@ tinyplot.default = function( # definition so dynmar_side uses theme mgp/tcl/las (which aren't in # par() yet since the before.plot.new hook hasn't fired). .tinytheme = get_tpar("tinytheme", default = "default") - .theme_def = if (!is.null(.tinytheme) && .tinytheme != "default") { - get(paste0("theme_", .tinytheme), envir = asNamespace("tinyplot")) - } else NULL + .theme_def = get_theme_def(.tinytheme) + if (identical(.theme_def, theme_default)) .theme_def = NULL .theme_mar = if (!is.null(.theme_def[["mar"]])) .theme_def[["mar"]] else par("mar") .tpars = if (!is.null(.theme_def)) modifyList(.theme_def, tpar()) else tpar() # Merge pending before.plot.new hook values into .tpars so user diff --git a/R/tinytheme.R b/R/tinytheme.R index b3c6cf7f..98239403 100644 --- a/R/tinytheme.R +++ b/R/tinytheme.R @@ -38,6 +38,10 @@ #' @param ... Named arguments to override specific theme settings. These #' arguments are passed to `tpar()` and take precedence over the predefined #' settings in the selected theme. +#' @param register Optional character string. If provided, the theme (with any +#' `...` overrides) is registered under this name via [`tinytheme_register()`] +#' and simultaneously activated. This is a shortcut for calling +#' [`tinytheme_register()`] and `tinytheme()` separately. #' #' @details #' Sets a list of graphical parameters using `tpar()` @@ -111,7 +115,8 @@ #' #' @return The function returns nothing. It is called for its side effects. #' -#' @seealso [`tpar`] which does the heavy lifting under the hood. +#' @seealso [`tpar`] which does the heavy lifting under the hood; +#' [tinytheme_register()] for registering custom named themes. #' #' @examples #' # Reusable plot function @@ -192,25 +197,19 @@ tinytheme = function( "ridge", "ridge2", "tufte", "float", "void" ), - ... + ..., + register = NULL ) { - theme = match.arg(theme) + if (length(theme) > 1) theme = theme[1] + + registered = names(get_environment_variable(".registered_themes")) + assert_choice(theme, c(builtin_themes, registered)) # in notebooks, we don't want to close the device because no image. # init_tpar() tries to be smart, but may fail. init_tpar(rm_hook = TRUE) - assert_choice( - theme, - c( - "default", - sort(c("basic", "broadsheet", "bw", "classic", "clean", "clean2", "dark", - "dynamic", "float", "ipsum", "ipsum2", "linedraw", "minimal", - "nber", "ridge", "ridge2", "socviz", "tufte", "void", "web")) - ) - ) - settings = switch(theme, "default" = theme_default, "basic" = theme_basic, @@ -233,6 +232,7 @@ tinytheme = function( "float" = theme_float, "void" = theme_void, "web" = theme_web, + get_environment_variable(".registered_themes")[[theme]] ) dots = list(...) @@ -262,10 +262,15 @@ tinytheme = function( settings[["mgp"]] = c(.mgp1, .mgp2, 0) } + if (!is.null(register)) { + tinytheme_register(register, theme = theme, ...) + settings[["tinytheme"]] = register + } + if (length(settings) > 0) { if (theme == "default") { # for default theme, we want to revert the original pars and turn off the - # before.new.plot hook (otherwise manual par(x = y) changes won't work) + # before.new.plot hook (otherwise manual par(x = y) changes won't work) tpar(settings, hook = FALSE) old_hooks = get_environment_variable(".tpar_hooks") remove_hooks(old_hooks) @@ -282,6 +287,15 @@ tinytheme = function( # ## Themes (these are read and set at initial load time) +builtin_themes = c( + "default", "basic", "dynamic", + "clean", "clean2", "bw", "linedraw", "classic", + "minimal", "ipsum", "ipsum2", "dark", + "socviz", "broadsheet", "nber", "web", + "ridge", "ridge2", + "tufte", "float", "void" +) + # theme_default = list() theme_default = list( @@ -645,3 +659,124 @@ theme_void = modifyList(theme_dynamic, list( xaxt = "none", yaxt = "none" )) + + +# +## Theme registry helpers +# + +# Internal: unified theme lookup (registered first, then built-in) +get_theme_def = function(name) { + if (is.null(name) || name == "default") return(theme_default) + registry = get_environment_variable(".registered_themes") + if (!is.null(registry[[name]])) return(registry[[name]]) + obj_name = paste0("theme_", name) + if (exists(obj_name, envir = asNamespace("tinyplot"), inherits = FALSE)) { + return(get(obj_name, envir = asNamespace("tinyplot"))) + } + NULL +} + + +#' Register, List, and Unregister Custom Themes +#' +#' @md +#' @description +#' `tinytheme_register()` registers a custom theme so it can be used by name +#' with `tinytheme()` or `tinyplot(..., theme = )`. Custom themes inherit from +#' a base theme and apply user-specified overrides. Registered themes are +#' session-scoped: they persist across plots but not across R sessions. To make +#' a custom theme permanently available, register it in your `.Rprofile`. +#' +#' `tinytheme_list()` returns the names of all available themes (built-in and +#' registered). +#' +#' `tinytheme_unregister()` removes a previously registered theme from the +#' registry. Does not reset an active theme. +#' +#' @param name Character string. The name for your custom theme. Cannot clash +#' with or overwrite a built-in theme name (`"default"`, `"clean"`, etc.) +#' @param theme Character string or list. The base theme to inherit from. If a +#' string, it must reference a built-in or previously-registered theme. If a +#' list, it is used directly as the base definition. Default is `"default"`. +#' @param ... Named arguments to override specific theme settings. These are +#' the same parameters accepted by `tpar()`. +#' +#' @return `tinytheme_register()` returns the theme definition list (invisibly). +#' `tinytheme_list()` returns a named list with character vectors `builtin` +#' and `registered`. `tinytheme_unregister()` returns `NULL` (invisibly). +#' +#' @seealso [tinytheme()] +#' +#' @examples +#' # Register a custom theme based on "float" but with a grid +#' tinytheme_register("float2", theme = "float", grid = TRUE) +#' +#' # Use it +#' tinyplot(1:5, theme = "float2") +#' +#' # List all themes +#' tinytheme_list() +#' +#' # Unregister +#' tinytheme_unregister("float2") +#' +#' @export +tinytheme_register = function(name, theme = "default", ...) { + if (!is.character(name) || length(name) != 1 || nchar(name) == 0) { + stop("`name` must be a single non-empty character string.", call. = FALSE) + } + builtins = builtin_themes + if (name %in% builtins) { + stop( + sprintf("'%s' is a built-in theme and cannot be overridden.", name), + call. = FALSE + ) + } + + if (is.character(theme) && length(theme) == 1) { + base_theme = get_theme_def(theme) + if (is.null(base_theme)) { + stop(sprintf("Base theme '%s' not found.", theme), call. = FALSE) + } + } else if (is.list(theme)) { + base_theme = theme + } else { + stop("`theme` must be a theme name (string) or a list.", call. = FALSE) + } + + dots = list(...) + new_theme = if (length(dots) > 0) modifyList(base_theme, dots) else base_theme + new_theme[["tinytheme"]] = name + + registry = get_environment_variable(".registered_themes") %||% list() + registry[[name]] = new_theme + set_environment_variable(.registered_themes = registry) + invisible(new_theme) +} + + +#' @rdname tinytheme_register +#' @export +tinytheme_list = function() { + builtins = builtin_themes + registered = names(get_environment_variable(".registered_themes")) + list(builtin = builtins, registered = registered) +} + + +#' @rdname tinytheme_register +#' @export +tinytheme_unregister = function(name) { + registry = get_environment_variable(".registered_themes") + if (!name %in% names(registry)) { + warning( + sprintf("Theme '%s' is not registered. Nothing to remove.", name), + call. = FALSE + ) + return(invisible(NULL)) + } + registry[[name]] = NULL + set_environment_variable(.registered_themes = registry) + invisible(NULL) +} diff --git a/altdoc/pkgdown.yml b/altdoc/pkgdown.yml index aeab3dda..534a46ad 100644 --- a/altdoc/pkgdown.yml +++ b/altdoc/pkgdown.yml @@ -2,7 +2,7 @@ altdoc: 0.7.2 pandoc: 3.9.0.2 pkgdown: 2.1.3 pkgdown_sha: ~ -last_built: 2026-06-02T22:46:25+0000 +last_built: 2026-06-04T16:46:48+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 972056f1..b135e3fe 100644 --- a/altdoc/quarto_website.yml +++ b/altdoc/quarto_website.yml @@ -64,6 +64,8 @@ website: file: man/tinyplot_add.qmd - text: tinytheme file: man/tinytheme.qmd + - text: tinytheme_register + file: man/tinytheme_register.qmd - section: "Plot types" contents: - section: Shapes @@ -96,6 +98,8 @@ website: file: man/type_barplot.qmd - text: type_boxplot file: man/type_boxplot.qmd + - text: type_chull + file: man/type_chull.qmd - text: type_density file: man/type_density.qmd - text: type_histogram diff --git a/inst/tinytest/_tinysnapshot/tinytheme_register_float2.svg b/inst/tinytest/_tinysnapshot/tinytheme_register_float2.svg new file mode 100644 index 00000000..0f7523e6 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/tinytheme_register_float2.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + +theme = "float2" +Registered theme test +Index +1:5 + + + + + + +1 +2 +3 +4 +5 + + + + + + +1 +2 +3 +4 +5 + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/test-tinytheme_register.R b/inst/tinytest/test-tinytheme_register.R new file mode 100644 index 00000000..aa78bb9a --- /dev/null +++ b/inst/tinytest/test-tinytheme_register.R @@ -0,0 +1,37 @@ +source("helpers.R") +using("tinysnapshot") + +# register a custom theme +tinytheme_register("float2", theme = "float", grid = TRUE, bg = "#f5e6c8") + +# snapshot: registered theme used ephemerally +f = function() tinyplot( + 1:5, + main = "Registered theme test", + sub = 'theme = "float2"', + theme = "float2" +) +expect_snapshot_plot(f, label = "tinytheme_register_float2") + +# error on unregistered name +expect_error( + tinytheme("float3"), + pattern = "must be one of", + info = "unregistered name errors" +) + +# register shortcut via tinytheme(..., register = ) +tinytheme("float", bg = "#f5e6c8", register = "float3") +expect_equal( + tpar("tinytheme"), "float3", + info = "register shortcut activates under registered name" +) +expect_true( + "float3" %in% tinytheme_list()[["registered"]], + info = "register shortcut adds to registry" +) +tinytheme() + +# clean up +tinytheme_unregister("float2") +tinytheme_unregister("float3") diff --git a/man/tinytheme.Rd b/man/tinytheme.Rd index 8bcf8782..78542b61 100644 --- a/man/tinytheme.Rd +++ b/man/tinytheme.Rd @@ -8,7 +8,8 @@ tinytheme( theme = c("default", "basic", "dynamic", "clean", "clean2", "bw", "linedraw", "classic", "minimal", "ipsum", "ipsum2", "dark", "socviz", "broadsheet", "nber", "web", "ridge", "ridge2", "tufte", "float", "void"), - ... + ..., + register = NULL ) } \arguments{ @@ -59,6 +60,11 @@ dynamic plots are marked with an asterisk (*) below. \item{...}{Named arguments to override specific theme settings. These arguments are passed to \code{tpar()} and take precedence over the predefined settings in the selected theme.} + +\item{register}{Optional character string. If provided, the theme (with any +\code{...} overrides) is registered under this name via \code{\link[=tinytheme_register]{tinytheme_register()}} +and simultaneously activated. This is a shortcut for calling +\code{\link[=tinytheme_register]{tinytheme_register()}} and \code{tinytheme()} separately.} } \value{ The function returns nothing. It is called for its side effects. @@ -208,5 +214,6 @@ tinytheme() } \seealso{ -\code{\link{tpar}} which does the heavy lifting under the hood. +\code{\link{tpar}} which does the heavy lifting under the hood; +\code{\link[=tinytheme_register]{tinytheme_register()}} for registering custom named themes. } diff --git a/man/tinytheme_register.Rd b/man/tinytheme_register.Rd new file mode 100644 index 00000000..b89b44a3 --- /dev/null +++ b/man/tinytheme_register.Rd @@ -0,0 +1,60 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/tinytheme.R +\name{tinytheme_register} +\alias{tinytheme_register} +\alias{tinytheme_list} +\alias{tinytheme_unregister} +\title{Register, List, and Unregister Custom Themes} +\usage{ +tinytheme_register(name, theme = "default", ...) + +tinytheme_list() + +tinytheme_unregister(name) +} +\arguments{ +\item{name}{Character string. The name for your custom theme. Cannot clash +with or overwrite a built-in theme name (\code{"default"}, \code{"clean"}, etc.)} + +\item{theme}{Character string or list. The base theme to inherit from. If a +string, it must reference a built-in or previously-registered theme. If a +list, it is used directly as the base definition. Default is \code{"default"}.} + +\item{...}{Named arguments to override specific theme settings. These are +the same parameters accepted by \code{tpar()}.} +} +\value{ +\code{tinytheme_register()} returns the theme definition list (invisibly). +\code{tinytheme_list()} returns a named list with character vectors \code{builtin} +and \code{registered}. \code{tinytheme_unregister()} returns \code{NULL} (invisibly). +} +\description{ +\code{tinytheme_register()} registers a custom theme so it can be used by name +with \code{tinytheme()} or \code{tinyplot(..., theme = )}. Custom themes inherit from +a base theme and apply user-specified overrides. Registered themes are +session-scoped: they persist across plots but not across R sessions. To make +a custom theme permanently available, register it in your \code{.Rprofile}. + +\code{tinytheme_list()} returns the names of all available themes (built-in and +registered). + +\code{tinytheme_unregister()} removes a previously registered theme from the +registry. Does not reset an active theme. +} +\examples{ +# Register a custom theme based on "float" but with a grid +tinytheme_register("float2", theme = "float", grid = TRUE) + +# Use it +tinyplot(1:5, theme = "float2") + +# List all themes +tinytheme_list() + +# Unregister +tinytheme_unregister("float2") + +} +\seealso{ +\code{\link[=tinytheme]{tinytheme()}} +} diff --git a/vignettes/themes.qmd b/vignettes/themes.qmd index deebf2d4..3e116e05 100644 --- a/vignettes/themes.qmd +++ b/vignettes/themes.qmd @@ -183,16 +183,25 @@ Please feel free to make suggestions about themes, or contribute new themes by ### Custom themes -Tweaking existing themes is easy. For example, the `tinytheme()` function also -accepts any graphical parameter supported by `tpar()`/`par()` and applies them -in persistent fashion. +Creating custom **tinyplot** themes is easy. In this section, we demonstrate how +to tweak aexisting themes in an _ad hoc_ manner, as well as how to register your +own custom themes for convenient re-use. + +#### _Ad hoc_ customization + +The `tinytheme()` function accepts all of the graphical parameters supported by +`(t)par()`. This means that you customize a persistent theme simply by passing +down the relevant parameter arguments. For example: ```{r} #| layout-ncol: 2 tinytheme( - "ipsum", - pch = 2, col.axis = "darkcyan", cex = 1.2, cex.main = 2, cex.lab = 1.5, - family = "HersheyScript" + "dynamic", + cex = 1.2, cex.main = 1.5, + col.axis = "darkred", col.main = "firebrick", + family = "HersheySans", + grid = TRUE, grid.col = "thistle", + pch = 2 ) tinyplot(mpg ~ hp, data = mtcars, main = "Fuel efficiency vs. horsepower") tinyplot(hp ~ mpg, data = mtcars, main = "Horsepower vs. fuel efficiency") @@ -204,7 +213,8 @@ tinytheme() ``` Similarly, you can pass a list object to the `themes` argument to customize -an ephemeral theme for a single plot. Here's a more fancy adaption that builds off the "dynamic" theme. +an ephemeral theme for a single plot. Here's a more fancy adaptation that builds +off the "dynamic" theme. ```{r} tinyplot( @@ -227,6 +237,74 @@ tinyplot( ) ``` +#### Registering custom themes + +If you find yourself reusing the same custom theme settings across multiple +plots or sessions, you may prefer to _register_ them as a named theme with +`tinytheme_register()`. Once a theme is registered, it works just like a +built-in one. So you can set it persistently with `tinytheme()`, or +pass it ephemerally with `tinyplot(..., theme = )`. + +Here's a (possibly ill-advised) example of a "pirate"-inspired theme: + +```{r} +# Register a custom "pirate" theme that builds on top of "clean" +tinytheme_register( + "pirate", + theme = "clean", + family = "HersheyScript", + bg = "#f5e6c8", fg = "#3b2209", + cex.lab = 1.5, cex.main = 1.5, cex.sub = 1.2, + col = "#3b2209", col.axis = "#5c3a1e", col.cap = "#7a5230", + col.lab = "#3b2209", col.main = "#1a0f04", col.sub = "#7a5230", + grid = TRUE, grid.col = "#c9a96e", grid.lty = "dotted", + facet.bg = "#e8d4a8", facet.border = "#5c3a1e", + pch = 4, + palette.qualitative = c( + "#8b0000", "#1a5276", "#196f3d", "#7d6608", + "#6c3483", "#a04000", "#1b4f72", "#145a32" + ) +) + +# Use it ephemerally +tinyplot( + Sepal.Length ~ Petal.Length | Species, iris, + main = 'Avast, me hearties!', + sub = 'Here be a "pirate" theme', + cap = '"x" marks the spot', + theme = 'pirate' +) +``` + +As a convenience, `tinytheme()` also accepts a `register` argument that +registers _and_ activates the theme in a single call: + +```r +# Equivalent to tinytheme_register("float2", ...) + tinytheme("float2") +tinytheme("float", grid = TRUE, register = "float2") +``` + +You can use `tinytheme_list()` to see all available themes (both built-in and +registered), and `tinytheme_unregister()` to remove one. + +```{r} +tinytheme_unregister("pirate") +``` + +Note that registered themes are session-scoped: they persist across plots but +disappear when R restarts. To make a custom theme permanently available, +register it in your `.Rprofile`: + +```r +# In ~/.Rprofile +if (requireNamespace("tinyplot", quietly = TRUE)) { + tinyplot::tinytheme_register("my_theme", theme = "clean", grid.col = "pink") +} +``` + +Similarly, package authors can ship their own custom (tiny)themes as part of +their codebase, which any subsequent `tinyplot()` calls can plug into. + ::: {.callout-tip} ## Font families @@ -248,6 +326,8 @@ It plays very nicely with **tinyplot**. ::: +#### Customization tips + One feature of the `tinytheme()` infrastructure that is especially relevant to customized themes is how dynamic spacing works. Dynamic themes in **tinyplot** automatically compute margin positions (`mar`, `mgp`) so that axis @@ -281,7 +361,6 @@ Again, this is much more convenient that fiddling with `mgp` values. But you can always provide `mgp` values if you wish; in which it will take precedence and the primitives are ignored. -::: {.callout-tip} To see the full list of parameters that defines a particular theme, simply assign them to an object. This can be helpful if you want to explore creating your own custom theme, or tweak an existing theme. @@ -296,8 +375,6 @@ parms = tinyplot:::theme_clean # doesn't assign the theme parms ``` -::: - As a final note about customizing themes, please note that `tinytheme` works by setting a persistent hook that resets parameters before each new plot. This is an efficient design choice, but it also means that calling `(t)par()`