Skip to content

Commit

Permalink
Clean up theme addition (#3570)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
clauswilke authored Oct 23, 2019
1 parent 115c396 commit 6f5ffea
Show file tree
Hide file tree
Showing 6 changed files with 109 additions and 83 deletions.
4 changes: 4 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
`colour = c("red", "green", "blue")`. Such use is discouraged and not officially supported
(@clauswilke, #3492).

* Addition of partial themes to plots has been made more predictable;
stepwise addition of individual partial themes is now equivalent to
addition of multple theme elements at once (@clauswilke, #3039).

* stacking text when calculating the labels and the y axis with
`stat_summary()` now works (@ikosmidis, #2709)

Expand Down
2 changes: 1 addition & 1 deletion R/plot-construction.r
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ ggplot_add.data.frame <- function(object, plot, object_name) {
}
#' @export
ggplot_add.theme <- function(object, plot, object_name) {
plot$theme <- update_theme(plot$theme, object)
plot$theme <- add_theme(plot$theme, object)
plot
}
#' @export
Expand Down
2 changes: 1 addition & 1 deletion R/theme-current.R
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ theme_get <- function() {
#' @param new new theme (a list of theme elements)
#' @export
theme_set <- function(new) {
missing <- setdiff(names(theme_gray()), names(new))
missing <- setdiff(names(ggplot_global$theme_grey), names(new))
if (length(missing) > 0) {
warning("New theme missing the following elements: ",
paste(missing, collapse = ", "), call. = FALSE)
Expand Down
140 changes: 64 additions & 76 deletions R/theme.r
Original file line number Diff line number Diff line change
Expand Up @@ -436,86 +436,31 @@ plot_theme <- function(x, default = theme_get()) {
#' @keywords internal
add_theme <- function(t1, t2, t2name) {
if (!is.theme(t2)) {
stop("Don't know how to add RHS to a theme object",
stop("Don't know how to add ", t2name, " to a theme object",
call. = FALSE)
}

# If t2 is a complete theme or t1 is NULL, just return t2
if (is_theme_complete(t2) || is.null(t1))
return(t2)

# Iterate over the elements that are to be updated
for (item in names(t2)) {
x <- t1[[item]]
y <- t2[[item]]

if (is.null(x) || inherits(x, "element_blank")) {
# If x is NULL or element_blank, then just assign it y
x <- y
} else if (is.null(y) || is.character(y) || is.numeric(y) || is.unit(y) ||
is.logical(y) || inherits(y, "element_blank")) {
# If y is NULL, or a string or numeric vector, or is element_blank, just replace x
x <- y
} else {
# If x is not NULL, then merge into y
x <- merge_element(y, x)
}
x <- merge_element(t2[[item]], t1[[item]])

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

# If either theme is complete, then the combined theme is complete
attr(t1, "complete") <- is_theme_complete(t1) || is_theme_complete(t2)
# make sure the "complete" attribute is set; this can be missing
# when t1 is an empty list
attr(t1, "complete") <- is_theme_complete(t1)
t1
}


# Update a theme from a plot object
#
# This is called from add_ggplot.
#
# If newtheme is a *complete* theme, then it is meant to replace
# oldtheme; this function just returns newtheme.
#
# Otherwise, it adds elements from newtheme to oldtheme:
# If oldtheme doesn't already contain those elements,
# it searches the current default theme, grabs the elements with the
# same name as those from newtheme, and puts them in oldtheme. Then
# it adds elements from newtheme to oldtheme.
# This makes it possible to do things like:
# ggplot(data.frame(x = 1:3, y = 1:3)) +
# geom_point() + theme(text = element_text(colour = 'red'))
# and have 'text' keep properties from the default theme. Otherwise
# you would have to set all the element properties, like family, size,
# etc.
#
# @param oldtheme an existing theme, usually from a plot object, like
# plot$theme. This could be an empty list.
# @param newtheme a new theme object to add to the existing theme
update_theme <- function(oldtheme, newtheme) {
# If the newtheme is a complete one, don't bother searching
# the default theme -- just replace everything with newtheme
if (is_theme_complete(newtheme))
return(newtheme)

# These are elements in newtheme that aren't already set in oldtheme.
# They will be pulled from the default theme.
newitems <- !names(newtheme) %in% names(oldtheme)
newitem_names <- names(newtheme)[newitems]
oldtheme[newitem_names] <- theme_get()[newitem_names]

# Update the theme elements with the things from newtheme
# Turn the 'theme' list into a proper theme object first, and preserve
# the 'complete' attribute. It's possible that oldtheme is an empty
# list, and in that case, set complete to FALSE.
old.validate <- isTRUE(attr(oldtheme, "validate"))
new.validate <- isTRUE(attr(newtheme, "validate"))
oldtheme <- do.call(theme, c(oldtheme,
complete = isTRUE(attr(oldtheme, "complete")),
validate = old.validate & new.validate))

oldtheme + newtheme
}

#' Calculate the element properties, by inheriting properties from its parents
#'
#' @param element The name of the theme element to calculate
Expand All @@ -539,16 +484,25 @@ update_theme <- function(oldtheme, newtheme) {
calc_element <- function(element, theme, verbose = FALSE) {
if (verbose) message(element, " --> ", appendLF = FALSE)

# If this is element_blank, don't inherit anything from parents
if (inherits(theme[[element]], "element_blank")) {
# if theme is not complete, merge element with theme defaults,
# otherwise take it as is. This fills in theme defaults if no
# explicit theme is set for the plot.
if (!is_theme_complete(theme)) {
el_out <- merge_element(theme[[element]], theme_get()[[element]])
} else {
el_out <- theme[[element]]
}

# If result is element_blank, don't inherit anything from parents
if (inherits(el_out, "element_blank")) {
if (verbose) message("element_blank (no inheritance)")
return(theme[[element]])
return(el_out)
}

# If the element is defined (and not just inherited), check that
# it is of the class specified in .element_tree
if (!is.null(theme[[element]]) &&
!inherits(theme[[element]], ggplot_global$element_tree[[element]]$class)) {
if (!is.null(el_out) &&
!inherits(el_out, ggplot_global$element_tree[[element]]$class)) {
stop(element, " should have class ", ggplot_global$element_tree[[element]]$class)
}

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

# If no parents, this is a "root" node. Just return this element.
if (is.null(pnames)) {
if (verbose) message("nothing (top level)")

# Check that all the properties of this element are non-NULL
nullprops <- vapply(theme[[element]], is.null, logical(1))
if (any(nullprops)) {
stop("Theme element '", element, "' has NULL property: ",
paste(names(nullprops)[nullprops], collapse = ", "))
nullprops <- vapply(el_out, is.null, logical(1))
if (!any(nullprops)) {
return(el_out) # no null properties, return element as is
}

if (verbose) message("nothing (top level)")
return(theme[[element]])
# if we have null properties, try to fill in from theme_grey()
el_out <- combine_elements(el_out, ggplot_global$theme_grey[[element]])
nullprops <- vapply(el_out, is.null, logical(1))
if (!any(nullprops)) {
return(el_out) # no null properties remaining, return element
}

stop("Theme element '", element, "' has NULL property without default: ",
paste(names(nullprops)[nullprops], collapse = ", "))
}

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

# Combine the properties of this element with all parents
Reduce(combine_elements, parents, theme[[element]])
Reduce(combine_elements, parents, el_out)
}

#' Merge a parent element into a child element
Expand All @@ -597,17 +559,43 @@ calc_element <- function(element, theme, verbose = FALSE) {
merge_element <- function(new, old) {
UseMethod("merge_element")
}

#' @rdname merge_element
#' @export
merge_element.default <- function(new, old) {
if (is.null(old) || inherits(old, "element_blank")) {
# If old is NULL or element_blank, then just return new
return(new)
} else if (is.null(new) || is.character(new) || is.numeric(new) || is.unit(new) ||
is.logical(new)) {
# If new is NULL, or a string, numeric vector, unit, or logical, just return it
return(new)
}

# otherwise we can't merge
stop("No method for merging ", class(new)[1], " into ", class(old)[1], call. = FALSE)
}

#' @rdname merge_element
#' @export
merge_element.element_blank <- function(new, old) {
# If new is element_blank, just return it
new
}

#' @rdname merge_element
#' @export
merge_element.element <- function(new, old) {
if (is.null(old) || inherits(old, "element_blank")) {
# If old is NULL or element_blank, then just return new
return(new)
}

# actual merging can only happen if classes match
if (!inherits(new, class(old)[1])) {
stop("Only elements of the same class can be merged", call. = FALSE)
}

# Override NULL properties of new with the values in old
# Get logical vector of NULL properties in new
idx <- vapply(new, is.null, logical(1))
Expand Down
4 changes: 3 additions & 1 deletion R/zzz.r
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ pathGrob <- NULL

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

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

# Used by rbind_dfs
date <- Sys.Date()
Expand Down
40 changes: 36 additions & 4 deletions tests/testthat/test-theme.r
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ test_that("modifying theme element properties with + operator works", {
})

test_that("adding theme object to ggplot object with + operator works", {

p <- qplot(1:3, 1:3)
## test with complete theme
p <- qplot(1:3, 1:3) + theme_grey()
p <- p + theme(axis.title = element_text(size = 20))
expect_true(p$theme$axis.title$size == 20)

Expand All @@ -55,6 +55,36 @@ test_that("adding theme object to ggplot object with + operator works", {
expect_true(tt$inherit.blank)
tt$inherit.blank <- FALSE
expect_identical(p$theme$text, tt)

## test without complete theme
p <- qplot(1:3, 1:3)
p <- p + theme(axis.title = element_text(size = 20))
expect_true(p$theme$axis.title$size == 20)

# Should update specified properties, but not reset other properties
p <- p + theme(text = element_text(colour = 'red'))
expect_true(p$theme$text$colour == 'red')
expect_null(p$theme$text$family)
expect_null(p$theme$text$face)
expect_null(p$theme$text$size)
expect_null(p$theme$text$hjust)
expect_null(p$theme$text$vjust)
expect_null(p$theme$text$angle)
expect_null(p$theme$text$lineheight)
expect_null(p$theme$text$margin)
expect_null(p$theme$text$debug)

## stepwise addition of partial themes is identical to one-step addition
p <- qplot(1:3, 1:3)
p1 <- p + theme_light() +
theme(axis.line.x = element_line(color = "blue")) +
theme(axis.ticks.x = element_line(color = "red"))

p2 <- p + theme_light() +
theme(axis.line.x = element_line(color = "blue"),
axis.ticks.x = element_line(color = "red"))

expect_identical(p1$theme, p2$theme)
})

test_that("replacing theme elements with %+replace% operator works", {
Expand Down Expand Up @@ -112,14 +142,16 @@ test_that("calculating theme element inheritance works", {
"panel.background",
theme(
rect = element_rect(fill = "white", colour = "black", size = 0.5, linetype = 1),
panel.background = element_dummyrect(dummy = 5))
panel.background = element_dummyrect(dummy = 5),
complete = TRUE # need to prevent pulling in default theme
)
)

expect_identical(
e,
structure(list(
fill = "white", colour = "black", dummy = 5, size = 0.5, linetype = 1,
inherit.blank = FALSE
inherit.blank = TRUE # this is true because we're requesting a complete theme
), class = c("element_dummyrect", "element_rect", "element"))
)
})
Expand Down

0 comments on commit 6f5ffea

Please sign in to comment.