Skip to content

Commit 6f5ffea

Browse files
authored
Clean up theme addition (#3570)
* clean up and simplify theme addition. fixes #3039 * more theme cleanup; simplify merging; correctly pull in theme defaults * add news item, one more unit test * cache theme_grey() so we don't have to rebuild it every time we need to look something up
1 parent 115c396 commit 6f5ffea

File tree

6 files changed

+109
-83
lines changed

6 files changed

+109
-83
lines changed

NEWS.md

+4
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
`colour = c("red", "green", "blue")`. Such use is discouraged and not officially supported
1919
(@clauswilke, #3492).
2020

21+
* Addition of partial themes to plots has been made more predictable;
22+
stepwise addition of individual partial themes is now equivalent to
23+
addition of multple theme elements at once (@clauswilke, #3039).
24+
2125
* stacking text when calculating the labels and the y axis with
2226
`stat_summary()` now works (@ikosmidis, #2709)
2327

R/plot-construction.r

+1-1
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ ggplot_add.data.frame <- function(object, plot, object_name) {
101101
}
102102
#' @export
103103
ggplot_add.theme <- function(object, plot, object_name) {
104-
plot$theme <- update_theme(plot$theme, object)
104+
plot$theme <- add_theme(plot$theme, object)
105105
plot
106106
}
107107
#' @export

R/theme-current.R

+1-1
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ theme_get <- function() {
7272
#' @param new new theme (a list of theme elements)
7373
#' @export
7474
theme_set <- function(new) {
75-
missing <- setdiff(names(theme_gray()), names(new))
75+
missing <- setdiff(names(ggplot_global$theme_grey), names(new))
7676
if (length(missing) > 0) {
7777
warning("New theme missing the following elements: ",
7878
paste(missing, collapse = ", "), call. = FALSE)

R/theme.r

+64-76
Original file line numberDiff line numberDiff line change
@@ -436,86 +436,31 @@ plot_theme <- function(x, default = theme_get()) {
436436
#' @keywords internal
437437
add_theme <- function(t1, t2, t2name) {
438438
if (!is.theme(t2)) {
439-
stop("Don't know how to add RHS to a theme object",
439+
stop("Don't know how to add ", t2name, " to a theme object",
440440
call. = FALSE)
441441
}
442442

443+
# If t2 is a complete theme or t1 is NULL, just return t2
444+
if (is_theme_complete(t2) || is.null(t1))
445+
return(t2)
446+
443447
# Iterate over the elements that are to be updated
444448
for (item in names(t2)) {
445-
x <- t1[[item]]
446-
y <- t2[[item]]
447-
448-
if (is.null(x) || inherits(x, "element_blank")) {
449-
# If x is NULL or element_blank, then just assign it y
450-
x <- y
451-
} else if (is.null(y) || is.character(y) || is.numeric(y) || is.unit(y) ||
452-
is.logical(y) || inherits(y, "element_blank")) {
453-
# If y is NULL, or a string or numeric vector, or is element_blank, just replace x
454-
x <- y
455-
} else {
456-
# If x is not NULL, then merge into y
457-
x <- merge_element(y, x)
458-
}
449+
x <- merge_element(t2[[item]], t1[[item]])
459450

460451
# Assign it back to t1
461452
# This is like doing t1[[item]] <- x, except that it preserves NULLs.
462453
# The other form will simply drop NULL values
463454
t1[item] <- list(x)
464455
}
465456

466-
# If either theme is complete, then the combined theme is complete
467-
attr(t1, "complete") <- is_theme_complete(t1) || is_theme_complete(t2)
457+
# make sure the "complete" attribute is set; this can be missing
458+
# when t1 is an empty list
459+
attr(t1, "complete") <- is_theme_complete(t1)
468460
t1
469461
}
470462

471463

472-
# Update a theme from a plot object
473-
#
474-
# This is called from add_ggplot.
475-
#
476-
# If newtheme is a *complete* theme, then it is meant to replace
477-
# oldtheme; this function just returns newtheme.
478-
#
479-
# Otherwise, it adds elements from newtheme to oldtheme:
480-
# If oldtheme doesn't already contain those elements,
481-
# it searches the current default theme, grabs the elements with the
482-
# same name as those from newtheme, and puts them in oldtheme. Then
483-
# it adds elements from newtheme to oldtheme.
484-
# This makes it possible to do things like:
485-
# ggplot(data.frame(x = 1:3, y = 1:3)) +
486-
# geom_point() + theme(text = element_text(colour = 'red'))
487-
# and have 'text' keep properties from the default theme. Otherwise
488-
# you would have to set all the element properties, like family, size,
489-
# etc.
490-
#
491-
# @param oldtheme an existing theme, usually from a plot object, like
492-
# plot$theme. This could be an empty list.
493-
# @param newtheme a new theme object to add to the existing theme
494-
update_theme <- function(oldtheme, newtheme) {
495-
# If the newtheme is a complete one, don't bother searching
496-
# the default theme -- just replace everything with newtheme
497-
if (is_theme_complete(newtheme))
498-
return(newtheme)
499-
500-
# These are elements in newtheme that aren't already set in oldtheme.
501-
# They will be pulled from the default theme.
502-
newitems <- !names(newtheme) %in% names(oldtheme)
503-
newitem_names <- names(newtheme)[newitems]
504-
oldtheme[newitem_names] <- theme_get()[newitem_names]
505-
506-
# Update the theme elements with the things from newtheme
507-
# Turn the 'theme' list into a proper theme object first, and preserve
508-
# the 'complete' attribute. It's possible that oldtheme is an empty
509-
# list, and in that case, set complete to FALSE.
510-
old.validate <- isTRUE(attr(oldtheme, "validate"))
511-
new.validate <- isTRUE(attr(newtheme, "validate"))
512-
oldtheme <- do.call(theme, c(oldtheme,
513-
complete = isTRUE(attr(oldtheme, "complete")),
514-
validate = old.validate & new.validate))
515-
516-
oldtheme + newtheme
517-
}
518-
519464
#' Calculate the element properties, by inheriting properties from its parents
520465
#'
521466
#' @param element The name of the theme element to calculate
@@ -539,16 +484,25 @@ update_theme <- function(oldtheme, newtheme) {
539484
calc_element <- function(element, theme, verbose = FALSE) {
540485
if (verbose) message(element, " --> ", appendLF = FALSE)
541486

542-
# If this is element_blank, don't inherit anything from parents
543-
if (inherits(theme[[element]], "element_blank")) {
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+
}
495+
496+
# If result is element_blank, don't inherit anything from parents
497+
if (inherits(el_out, "element_blank")) {
544498
if (verbose) message("element_blank (no inheritance)")
545-
return(theme[[element]])
499+
return(el_out)
546500
}
547501

548502
# If the element is defined (and not just inherited), check that
549503
# it is of the class specified in .element_tree
550-
if (!is.null(theme[[element]]) &&
551-
!inherits(theme[[element]], ggplot_global$element_tree[[element]]$class)) {
504+
if (!is.null(el_out) &&
505+
!inherits(el_out, ggplot_global$element_tree[[element]]$class)) {
552506
stop(element, " should have class ", ggplot_global$element_tree[[element]]$class)
553507
}
554508

@@ -557,23 +511,31 @@ calc_element <- function(element, theme, verbose = FALSE) {
557511

558512
# If no parents, this is a "root" node. Just return this element.
559513
if (is.null(pnames)) {
514+
if (verbose) message("nothing (top level)")
515+
560516
# Check that all the properties of this element are non-NULL
561-
nullprops <- vapply(theme[[element]], is.null, logical(1))
562-
if (any(nullprops)) {
563-
stop("Theme element '", element, "' has NULL property: ",
564-
paste(names(nullprops)[nullprops], collapse = ", "))
517+
nullprops <- vapply(el_out, is.null, logical(1))
518+
if (!any(nullprops)) {
519+
return(el_out) # no null properties, return element as is
565520
}
566521

567-
if (verbose) message("nothing (top level)")
568-
return(theme[[element]])
522+
# if we have null properties, try to fill in from theme_grey()
523+
el_out <- combine_elements(el_out, ggplot_global$theme_grey[[element]])
524+
nullprops <- vapply(el_out, is.null, logical(1))
525+
if (!any(nullprops)) {
526+
return(el_out) # no null properties remaining, return element
527+
}
528+
529+
stop("Theme element '", element, "' has NULL property without default: ",
530+
paste(names(nullprops)[nullprops], collapse = ", "))
569531
}
570532

571533
# Calculate the parent objects' inheritance
572534
if (verbose) message(paste(pnames, collapse = ", "))
573535
parents <- lapply(pnames, calc_element, theme, verbose)
574536

575537
# Combine the properties of this element with all parents
576-
Reduce(combine_elements, parents, theme[[element]])
538+
Reduce(combine_elements, parents, el_out)
577539
}
578540

579541
#' Merge a parent element into a child element
@@ -597,17 +559,43 @@ calc_element <- function(element, theme, verbose = FALSE) {
597559
merge_element <- function(new, old) {
598560
UseMethod("merge_element")
599561
}
562+
600563
#' @rdname merge_element
601564
#' @export
602565
merge_element.default <- function(new, old) {
566+
if (is.null(old) || inherits(old, "element_blank")) {
567+
# If old is NULL or element_blank, then just return new
568+
return(new)
569+
} else if (is.null(new) || is.character(new) || is.numeric(new) || is.unit(new) ||
570+
is.logical(new)) {
571+
# If new is NULL, or a string, numeric vector, unit, or logical, just return it
572+
return(new)
573+
}
574+
575+
# otherwise we can't merge
603576
stop("No method for merging ", class(new)[1], " into ", class(old)[1], call. = FALSE)
604577
}
578+
579+
#' @rdname merge_element
580+
#' @export
581+
merge_element.element_blank <- function(new, old) {
582+
# If new is element_blank, just return it
583+
new
584+
}
585+
605586
#' @rdname merge_element
606587
#' @export
607588
merge_element.element <- function(new, old) {
589+
if (is.null(old) || inherits(old, "element_blank")) {
590+
# If old is NULL or element_blank, then just return new
591+
return(new)
592+
}
593+
594+
# actual merging can only happen if classes match
608595
if (!inherits(new, class(old)[1])) {
609596
stop("Only elements of the same class can be merged", call. = FALSE)
610597
}
598+
611599
# Override NULL properties of new with the values in old
612600
# Get logical vector of NULL properties in new
613601
idx <- vapply(new, is.null, logical(1))

R/zzz.r

+3-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ pathGrob <- NULL
3333

3434
.zeroGrob <<- grob(cl = "zeroGrob", name = "NULL")
3535

36-
ggplot_global$theme_current <- theme_gray()
36+
# create default theme, store for later use, and set as current theme
37+
ggplot_global$theme_grey <- theme_grey()
38+
ggplot_global$theme_current <- ggplot_global$theme_grey
3739

3840
# Used by rbind_dfs
3941
date <- Sys.Date()

tests/testthat/test-theme.r

+36-4
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ test_that("modifying theme element properties with + operator works", {
4242
})
4343

4444
test_that("adding theme object to ggplot object with + operator works", {
45-
46-
p <- qplot(1:3, 1:3)
45+
## test with complete theme
46+
p <- qplot(1:3, 1:3) + theme_grey()
4747
p <- p + theme(axis.title = element_text(size = 20))
4848
expect_true(p$theme$axis.title$size == 20)
4949

@@ -55,6 +55,36 @@ test_that("adding theme object to ggplot object with + operator works", {
5555
expect_true(tt$inherit.blank)
5656
tt$inherit.blank <- FALSE
5757
expect_identical(p$theme$text, tt)
58+
59+
## test without complete theme
60+
p <- qplot(1:3, 1:3)
61+
p <- p + theme(axis.title = element_text(size = 20))
62+
expect_true(p$theme$axis.title$size == 20)
63+
64+
# Should update specified properties, but not reset other properties
65+
p <- p + theme(text = element_text(colour = 'red'))
66+
expect_true(p$theme$text$colour == 'red')
67+
expect_null(p$theme$text$family)
68+
expect_null(p$theme$text$face)
69+
expect_null(p$theme$text$size)
70+
expect_null(p$theme$text$hjust)
71+
expect_null(p$theme$text$vjust)
72+
expect_null(p$theme$text$angle)
73+
expect_null(p$theme$text$lineheight)
74+
expect_null(p$theme$text$margin)
75+
expect_null(p$theme$text$debug)
76+
77+
## stepwise addition of partial themes is identical to one-step addition
78+
p <- qplot(1:3, 1:3)
79+
p1 <- p + theme_light() +
80+
theme(axis.line.x = element_line(color = "blue")) +
81+
theme(axis.ticks.x = element_line(color = "red"))
82+
83+
p2 <- p + theme_light() +
84+
theme(axis.line.x = element_line(color = "blue"),
85+
axis.ticks.x = element_line(color = "red"))
86+
87+
expect_identical(p1$theme, p2$theme)
5888
})
5989

6090
test_that("replacing theme elements with %+replace% operator works", {
@@ -112,14 +142,16 @@ test_that("calculating theme element inheritance works", {
112142
"panel.background",
113143
theme(
114144
rect = element_rect(fill = "white", colour = "black", size = 0.5, linetype = 1),
115-
panel.background = element_dummyrect(dummy = 5))
145+
panel.background = element_dummyrect(dummy = 5),
146+
complete = TRUE # need to prevent pulling in default theme
147+
)
116148
)
117149

118150
expect_identical(
119151
e,
120152
structure(list(
121153
fill = "white", colour = "black", dummy = 5, size = 0.5, linetype = 1,
122-
inherit.blank = FALSE
154+
inherit.blank = TRUE # this is true because we're requesting a complete theme
123155
), class = c("element_dummyrect", "element_rect", "element"))
124156
)
125157
})

0 commit comments

Comments
 (0)