Skip to content

Commit f418e92

Browse files
authored
Enable user-defined theme elements by making element tree part of the theme. (#2784)
* move theme validation to plot construction * provide element tree as theme attribute * pull element tree first from default theme, then from internal defaults * always pull entirely missing elements from the default theme * fix and improve documentation * add news item. Closes #2540.
1 parent f0ce285 commit f418e92

File tree

10 files changed

+326
-44
lines changed

10 files changed

+326
-44
lines changed

NAMESPACE

+2
Original file line numberDiff line numberDiff line change
@@ -299,10 +299,12 @@ export(draw_key_timeseries)
299299
export(draw_key_vline)
300300
export(draw_key_vpath)
301301
export(dup_axis)
302+
export(el_def)
302303
export(element_blank)
303304
export(element_grob)
304305
export(element_line)
305306
export(element_rect)
307+
export(element_render)
306308
export(element_text)
307309
export(enexpr)
308310
export(enexprs)

NEWS.md

+4
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
* `Geom` now gains a `setup_params()` method in line with the other ggproto
2121
classes (@thomasp85, #3509)
2222

23+
* Themes can now modify the theme element tree, via the
24+
`element_tree` argument. This allows extension packages to add functionality that
25+
alters the element tree (@clauswilke, #2540).
26+
2327
* `element_text()` now issues a warning when vectorized arguments are provided, as in
2428
`colour = c("red", "green", "blue")`. Such use is discouraged and not officially supported
2529
(@clauswilke, #3492).

R/theme-current.R

+12
Original file line numberDiff line numberDiff line change
@@ -104,5 +104,17 @@ theme_replace <- function(...) {
104104

105105
# Can't use modifyList here since it works recursively and drops NULLs
106106
e1[names(e2)] <- e2
107+
108+
# Merge element trees if provided
109+
attr(e1, "element_tree") <- defaults(
110+
attr(e2, "element_tree", exact = TRUE),
111+
attr(e1, "element_tree", exact = TRUE)
112+
)
113+
114+
# comment by @clauswilke:
115+
# `complete` and `validate` are currently ignored,
116+
# which means they are taken from e1. Is this correct?
117+
# I'm not sure how `%+replace%` should handle them.
118+
107119
e1
108120
}

R/theme-elements.r

+64-16
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
#' - `element_text`: text.
1111
#'
1212
#' `rel()` is used to specify sizes relative to the parent,
13-
#' `margins()` is used to specify the margins of elements.
13+
#' `margin()` is used to specify the margins of elements.
1414
#'
1515
#' @param fill Fill colour.
1616
#' @param colour,color Line/border colour. Color is an alias for colour.
@@ -154,13 +154,22 @@ print.rel <- function(x, ...) print(noquote(paste(x, " *", sep = "")))
154154
#' @keywords internal
155155
is.rel <- function(x) inherits(x, "rel")
156156

157-
# Given a theme object and element name, return a grob for the element
157+
#' Render a specified theme element into a grob
158+
#'
159+
#' Given a theme object and element name, returns a grob for the element.
160+
#' Uses [`element_grob()`] to generate the grob.
161+
#' @param theme The theme object
162+
#' @param element The element name given as character vector
163+
#' @param ... Other arguments provided to [`element_grob()`]
164+
#' @param name Character vector added to the name of the grob
165+
#' @keywords internal
166+
#' @export
158167
element_render <- function(theme, element, ..., name = NULL) {
159168

160169
# Get the element from the theme, calculating inheritance
161170
el <- calc_element(element, theme)
162171
if (is.null(el)) {
163-
message("Theme element ", element, " missing")
172+
message("Theme element `", element, "` missing")
164173
return(zeroGrob())
165174
}
166175

@@ -263,13 +272,51 @@ element_grob.element_line <- function(element, x = 0:1, y = 0:1,
263272

264273

265274

266-
# Define an element's class and what other elements it inherits from
267-
#
268-
# @param class The name of class (like "element_line", "element_text",
269-
# or the reserved "character", which means a character vector (not
270-
# "character" class)
271-
# @param inherit A vector of strings, naming the elements that this
272-
# element inherits from.
275+
#' Define new elements for a theme's element tree
276+
#'
277+
#' Each theme has an element tree that defines which theme elements inherit
278+
#' theme parameters from which other elements. The function `el_def()` can be used
279+
#' to define new or modified elements for this tree.
280+
#'
281+
#' @param class The name of the element class. Examples are "element_line" or
282+
#' "element_text" or "unit", or one of the two reserved keywords "character" or
283+
#' "margin". The reserved keyword "character" implies a character
284+
#' or numeric vector, not a class called "character". The keyword
285+
#' "margin" implies a unit vector of length 4, as created by [margin()].
286+
#' @param inherit A vector of strings, naming the elements that this
287+
#' element inherits from.
288+
#' @param description An optional character vector providing a description
289+
#' for the element.
290+
#' @examples
291+
#' # define a new coord that includes a panel annotation
292+
#' coord_annotate <- function(label = "panel annotation") {
293+
#' ggproto(NULL, CoordCartesian,
294+
#' limits = list(x = NULL, y = NULL),
295+
#' expand = TRUE,
296+
#' default = FALSE,
297+
#' clip = "on",
298+
#' render_fg = function(panel_params, theme) {
299+
#' element_render(theme, "panel.annotation", label = label)
300+
#' }
301+
#' )
302+
#' }
303+
#'
304+
#' # update the default theme by adding a new `panel.annotation`
305+
#' # theme element
306+
#' old <- theme_update(
307+
#' panel.annotation = element_text(color = "blue", hjust = 0.95, vjust = 0.05),
308+
#' element_tree = list(panel.annotation = el_def("element_text", "text"))
309+
#' )
310+
#'
311+
#' df <- data.frame(x = 1:3, y = 1:3)
312+
#' ggplot(df, aes(x, y)) +
313+
#' geom_point() +
314+
#' coord_annotate("annotation in blue")
315+
#'
316+
#' # revert to original default theme
317+
#' theme_set(old)
318+
#' @keywords internal
319+
#' @export
273320
el_def <- function(class = NULL, inherit = NULL, description = NULL) {
274321
list(class = class, inherit = inherit, description = description)
275322
}
@@ -393,11 +440,12 @@ ggplot_global$element_tree <- .element_tree
393440
#
394441
# @param el an element
395442
# @param elname the name of the element
396-
validate_element <- function(el, elname) {
397-
eldef <- ggplot_global$element_tree[[elname]]
443+
# @param element_tree the element tree to validate against
444+
validate_element <- function(el, elname, element_tree) {
445+
eldef <- element_tree[[elname]]
398446

399447
if (is.null(eldef)) {
400-
stop('"', elname, '" is not a valid theme element name.')
448+
stop("Theme element `", elname, "` is not defined in the element hierarchy.", call. = FALSE)
401449
}
402450

403451
# NULL values for elements are OK
@@ -407,12 +455,12 @@ validate_element <- function(el, elname) {
407455
# Need to be a bit looser here since sometimes it's a string like "top"
408456
# but sometimes its a vector like c(0,0)
409457
if (!is.character(el) && !is.numeric(el))
410-
stop("Element ", elname, " must be a string or numeric vector.")
458+
stop("Theme element `", elname, "` must be a string or numeric vector.", call. = FALSE)
411459
} else if (eldef$class == "margin") {
412460
if (!is.unit(el) && length(el) == 4)
413-
stop("Element ", elname, " must be a unit vector of length 4.")
461+
stop("Theme element `", elname, "` must be a unit vector of length 4.", call. = FALSE)
414462
} else if (!inherits(el, eldef$class) && !inherits(el, "element_blank")) {
415-
stop("Element ", elname, " must be a ", eldef$class, " object.")
463+
stop("Theme element `", elname, "` must be an `", eldef$class, "` object.", call. = FALSE)
416464
}
417465
invisible()
418466
}

R/theme.r

+85-25
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
#' about theme inheritance below.
1010
#'
1111
#' @section Theme inheritance:
12-
#' Theme elements inherit properties from other theme elements heirarchically.
12+
#' Theme elements inherit properties from other theme elements hierarchically.
1313
#' For example, `axis.title.x.bottom` inherits from `axis.title.x` which inherits
1414
#' from `axis.title`, which in turn inherits from `text`. All text elements inherit
1515
#' directly or indirectly from `text`; all lines inherit from
@@ -164,6 +164,10 @@
164164
#' `complete = TRUE` all elements will be set to inherit from blank
165165
#' elements.
166166
#' @param validate `TRUE` to run `validate_element()`, `FALSE` to bypass checks.
167+
#' @param element_tree optional addition or modification to the element tree,
168+
#' which specifies the inheritance relationship of the theme elements. The element
169+
#' tree should be provided as a list of named element definitions created with
170+
#' [`el_def()`]. See [`el_def()`] for more details.
167171
#'
168172
#' @seealso
169173
#' [+.gg()] and \code{\link{\%+replace\%}},
@@ -358,9 +362,10 @@ theme <- function(line,
358362
strip.switch.pad.wrap,
359363
...,
360364
complete = FALSE,
361-
validate = TRUE
365+
validate = TRUE,
366+
element_tree = NULL
362367
) {
363-
elements <- find_args(..., complete = NULL, validate = NULL)
368+
elements <- find_args(..., complete = NULL, validate = NULL, element_tree = NULL)
364369

365370
if (!is.null(elements$axis.ticks.margin)) {
366371
warning("`axis.ticks.margin` is deprecated. Please set `margin` property ",
@@ -392,11 +397,6 @@ theme <- function(line,
392397
elements$legend.margin <- margin()
393398
}
394399

395-
# Check that all elements have the correct class (element_text, unit, etc)
396-
if (validate) {
397-
mapply(validate_element, elements, names(elements))
398-
}
399-
400400
# If complete theme set all non-blank elements to inherit from blanks
401401
if (complete) {
402402
elements <- lapply(elements, function(el) {
@@ -410,21 +410,69 @@ theme <- function(line,
410410
elements,
411411
class = c("theme", "gg"),
412412
complete = complete,
413-
validate = validate
413+
validate = validate,
414+
element_tree = element_tree
414415
)
415416
}
416417

417-
is_theme_complete <- function(x) isTRUE(attr(x, "complete"))
418+
# check whether theme is complete
419+
is_theme_complete <- function(x) isTRUE(attr(x, "complete", exact = TRUE))
418420

421+
# check whether theme should be validated
422+
is_theme_validate <- function(x) {
423+
validate <- attr(x, "validate", exact = TRUE)
424+
if (is.null(validate))
425+
TRUE # we validate by default
426+
else
427+
isTRUE(validate)
428+
}
429+
430+
# obtain the full element tree from a theme,
431+
# substituting the defaults if needed
432+
complete_element_tree <- function(theme) {
433+
element_tree <- attr(theme, "element_tree", exact = TRUE)
434+
435+
# we fill in the element tree first from the current default theme,
436+
# and then from the internal element tree if necessary
437+
# this makes it easy for extension packages to provide modified
438+
# default element trees
439+
defaults(
440+
defaults(
441+
element_tree,
442+
attr(theme_get(), "element_tree", exact = TRUE)
443+
),
444+
ggplot_global$element_tree
445+
)
446+
}
419447

420448
# Combine plot defaults with current theme to get complete theme for a plot
421449
plot_theme <- function(x, default = theme_get()) {
422450
theme <- x$theme
451+
452+
# apply theme defaults appropriately if needed
423453
if (is_theme_complete(theme)) {
424-
theme
454+
# for complete themes, we fill in missing elements but don't do any element merging
455+
# can't use `defaults()` because it strips attributes
456+
missing <- setdiff(names(default), names(theme))
457+
theme[missing] <- default[missing]
425458
} else {
426-
defaults(theme, default)
459+
# otherwise, we can just add the theme to the default theme
460+
theme <- default + theme
427461
}
462+
463+
# complete the element tree and save back to the theme
464+
element_tree <- complete_element_tree(theme)
465+
attr(theme, "element_tree") <- element_tree
466+
467+
# Check that all elements have the correct class (element_text, unit, etc)
468+
if (is_theme_validate(theme)) {
469+
mapply(
470+
validate_element, theme, names(theme),
471+
MoreArgs = list(element_tree = element_tree)
472+
)
473+
}
474+
475+
theme
428476
}
429477

430478
#' Modify properties of an element in a theme object
@@ -435,7 +483,7 @@ plot_theme <- function(x, default = theme_get()) {
435483
#' informative error messages.
436484
#' @keywords internal
437485
add_theme <- function(t1, t2, t2name) {
438-
if (!is.theme(t2)) {
486+
if (!is.list(t2)) { # in various places in the code base, simple lists are used as themes
439487
stop("Can't add `", t2name, "` to a theme object.",
440488
call. = FALSE)
441489
}
@@ -457,6 +505,17 @@ add_theme <- function(t1, t2, t2name) {
457505
# make sure the "complete" attribute is set; this can be missing
458506
# when t1 is an empty list
459507
attr(t1, "complete") <- is_theme_complete(t1)
508+
509+
# Only validate if both themes should be validated
510+
attr(t1, "validate") <-
511+
is_theme_validate(t1) && is_theme_validate(t2)
512+
513+
# Merge element trees if provided
514+
attr(t1, "element_tree") <- defaults(
515+
attr(t2, "element_tree", exact = TRUE),
516+
attr(t1, "element_tree", exact = TRUE)
517+
)
518+
460519
t1
461520
}
462521

@@ -484,30 +543,31 @@ add_theme <- function(t1, t2, t2name) {
484543
calc_element <- function(element, theme, verbose = FALSE) {
485544
if (verbose) message(element, " --> ", appendLF = FALSE)
486545

487-
# if theme is not complete, merge element with theme defaults,
488-
# otherwise take it as is. This fills in theme defaults if no
489-
# explicit theme is set for the plot.
490-
if (!is_theme_complete(theme)) {
491-
el_out <- merge_element(theme[[element]], theme_get()[[element]])
492-
} else {
493-
el_out <- theme[[element]]
494-
}
546+
el_out <- theme[[element]]
495547

496548
# If result is element_blank, don't inherit anything from parents
497549
if (inherits(el_out, "element_blank")) {
498550
if (verbose) message("element_blank (no inheritance)")
499551
return(el_out)
500552
}
501553

554+
# Obtain the element tree and check that the element is in it
555+
# If not, try to retrieve the complete element tree. This is
556+
# needed for backwards compatibility and certain unit tests.
557+
element_tree <- attr(theme, "element_tree", exact = TRUE)
558+
if (!element %in% names(element_tree)) {
559+
element_tree <- complete_element_tree(theme)
560+
}
561+
502562
# If the element is defined (and not just inherited), check that
503-
# it is of the class specified in .element_tree
563+
# it is of the class specified in element_tree
504564
if (!is.null(el_out) &&
505-
!inherits(el_out, ggplot_global$element_tree[[element]]$class)) {
506-
stop(element, " should have class ", ggplot_global$element_tree[[element]]$class)
565+
!inherits(el_out, element_tree[[element]]$class)) {
566+
stop(element, " should have class ", element_tree[[element]]$class)
507567
}
508568

509569
# Get the names of parents from the inheritance tree
510-
pnames <- ggplot_global$element_tree[[element]]$inherit
570+
pnames <- element_tree[[element]]$inherit
511571

512572
# If no parents, this is a "root" node. Just return this element.
513573
if (is.null(pnames)) {

0 commit comments

Comments
 (0)