Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable user-defined theme elements by making element tree part of the theme. #2784

Merged
merged 17 commits into from Nov 15, 2019

Conversation

clauswilke
Copy link
Member

@clauswilke clauswilke commented Jul 28, 2018

This PR addresses issue #2540, enabling of user-defined theme elements.

I have added the option to change the element tree of the theme through the theme itself. The main change I had to make to make this happen is that I had to move theme validation from the theme() function to plot rendering. This means that with this PR the theme() function takes pretty much anything without complaint, the error happens only once we have the entire theme for rendering:

library(ggplot2)

# not an error anymore, validation has been moved to
# final plot rendering
t <- theme_gray() + theme(text = 0)

# error
ggplot() + t
#> Error: Theme element `text` must be an `element_text` object.

# without validation, we get a different error
ggplot() + theme(text = 0, validate = FALSE)
#> Error in FUN(X[[i]], ...): text should have class element_text

Created on 2018-07-28 by the reprex package (v0.2.0).

Now, as an example of how this works, we create a new element type and use it instead of element_text() for the x axis title:

library(ggplot2)
library(rlang)

# define a new text element that rotates text 90 degrees relative to the angle provided.
# this could be done more easily by making "element_rot_text" derive from "element_text", 
# but the point here is to show that this can now be done without inheritance.
element_rot_text <- function(family = NULL, face = NULL, colour = NULL,
                             size = NULL, hjust = NULL, vjust = NULL, angle = NULL, lineheight = NULL,
                             color = NULL, margin = NULL, debug = NULL, inherit.blank = FALSE) {
  
  if (!is.null(color))  colour <- color
  structure(
    list(family = family, face = face, colour = colour, size = size,
         hjust = hjust, vjust = vjust, angle = angle, lineheight = lineheight,
         margin = margin, debug = debug, inherit.blank = inherit.blank),
    class = c("element_rot_text", "element")
  )
}

element_grob.element_rot_text <- function(element, angle = NULL, ...) {
  angle <- (angle %||% 0) + 90
  ggplot2:::element_grob.element_text(element, angle = angle, ...)
}

merge_element.element_rot_text <- function(new, old) {
  if (!inherits(old, "element_rot_text") && 
      !inherits(old, "element_text")) {
    stop("Can't merge elements", 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))
  # Get the names of TRUE items
  idx <- names(idx[idx])
  
  # Update non-NULL items
  new[idx] <- old[idx]
  
  new
}

# make plot
p <- ggplot(iris, aes(Sepal.Length, Sepal.Width, color = Species)) +
  geom_point()

# error, we haven't changed the element tree
p + theme(axis.title.x = element_rot_text())
#> Error: Theme element `axis.title.x` must be an `element_text` object.

# it works if we change the element tree appropriately
p + theme(
  axis.title.x = element_rot_text(),
  element_tree = list(
    axis.title.x = el_def("element_rot_text", "text")
  )
)

Created on 2018-07-28 by the reprex package (v0.2.0).

@clauswilke
Copy link
Member Author

@hadley @thomasp85 As part of the performance improvements, I'd like to suggest that there's an opportunity for optimization that is related to this PR (though not currently implemented in this PR). The main things this PR does is move the theme validation step to right before plot build, rather than when theme() is called as is the case currently. With this change, as part of the validation we could precompute and then cache the final theme elements. Currently, they are computed repeatedly on the fly as the plot is built. While this might not have a big performance effect on a single plot, I would assume that it should be a measurable effect in gganimate where the same theme elements are calculated over and over.

I'm happy to look into implementing such a theme caching mechanism. The main question is whether we're Ok with not validating themes when theme() is called.

@hadley
Copy link
Member

hadley commented Jan 19, 2019

I didn't review the code, but the overall justification sounds good and I'm happy for this code to be merged (after someone has taken a closer look at it)

@thomasp85

This comment has been minimized.

@thomasp85
Copy link
Member

One thing Id like to have somehow with a new theme setup is the ability of extension packages to register new theme elements and defaults/inheritance for these during loading. An example is the indicator element in geforce:: facet_zoom() that currently piggy-backs on the strip theming...

@clauswilke

This comment has been minimized.

@thomasp85
Copy link
Member

Agreed... We'll have a go after 3.2.0

@clauswilke
Copy link
Member Author

@thomasp85 I just saw the 3.3.0 milestone. Do we have a feature-freeze date for that milestone? Is it still possible to finalize this PR for that milestone? I know I've been sitting on this for a long time but I should be able to get this done over the next two weeks.

@thomasp85
Copy link
Member

We don’t have an immediate feature freeze date. If you think you can get this finalised in October/ start November it should be fine

@clauswilke

This comment has been minimized.

@clauswilke clauswilke added this to the ggplot2 3.3.0 milestone Oct 14, 2019
@clauswilke

This comment has been minimized.

@thomasp85
Copy link
Member

Can you give an example of using the element_tree argument? I'm not sure I grog the use just by looking at your code...

@clauswilke
Copy link
Member Author

I provided an example at the end of the very first post on this issue, here:
#2784 (comment)
Does this help or are you looking for something else?

Arguably this particular example is contrived. The real use is for packages like ggtern that need to define their own theme elements. For example, let's say somebody writes a package that can create facets with individual captions. Then, they could add a theme element facet.caption like this:

+ theme(
  facet_caption = element_text(size = 10),
  element_tree = list(
    facet_caption = el_def("element_text", "text")
  )

The element tree gets merged into the existing tree, so there's no need to repeat any definitions that are already in the default theme.

@clauswilke
Copy link
Member Author

@thomasp85 I made some updates to the PR that make it easier for extension developers to provide functionality that requires new theme elements. As an example, see the reprex below, which implements a new coord that places an annotation into the plot panel and uses a new theme element panel.annotation to style it.

library(ggplot2)

# define a new coord that adds an annotation label
coord_annotate <- function(label = "panel annotation") {
  ggproto(NULL, CoordCartesian,
    limits = list(x = NULL, y = NULL),
    expand = TRUE,
    default = FALSE,
    clip = "on",
    render_fg = function(panel_params, theme) {
      element_render(theme, "panel.annotation", label = label)
    }
  )
}

df <- data.frame(x = 1:10, y = 1:10)

# doesn't work, element `panel.annotation` hasn't been defined yet 
ggplot(df, aes(x, y)) +
  geom_point() +
  coord_annotate()
#> Theme element `panel.annotation` missing

# element can be defined via theme() on the fly
ggplot(df, aes(x, y)) +
  geom_point() +
  coord_annotate() +
  theme(
    panel.annotation = element_text(color = "blue", hjust = 0.95, vjust = 0.05),
    element_tree = list(panel.annotation = el_def("element_text", "text"))
  )

# or the default theme can be updated; this would be recommended for
# extension packages
old <- theme_update(
  panel.annotation = element_text(color = "blue", hjust = 0.95, vjust = 0.05),
  element_tree = list(panel.annotation = el_def("element_text", "text"))
)

ggplot(df, aes(x, y)) +
  geom_point() +
  coord_annotate()

# this approach works also with alternative themes
ggplot(df, aes(x, y)) +
  geom_point() +
  coord_annotate() +
  theme_classic()

# reverting back to old theme restores the old element tree
theme_set(old)

ggplot(df, aes(x, y)) +
  geom_point() +
  coord_annotate() +
  theme_classic()
#> Theme element `panel.annotation` missing

Created on 2019-10-23 by the reprex package (v0.3.0)

Copy link
Member

@thomasp85 thomasp85 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants