Dynamic Tabsets in Quarto - An Update

In a previous post, knitr_tabset was introduced as a mean of programmatic tabsets. Can we improve this function using knit_print?

quarto
Author

Josh Cowley

Published

November 10, 2022

Tl;dr

Knitr is printing via a generic knit_print meaning tabsets can be made automatic by including a specific method for lists.

If the jcutils package is installed (from GitHub) then,

registerS3method("knit_print", "list", jcutils::printer_tabset)

will convert list (including nested and ragged list) output to tabsets.

Previous Version

In a previous blog post, I introduced a function that would take a list and print the contents as tabsets.

A few issues with this method include

  • the need for the results: asis chunk option,

  • the workaround (and knowledge) needed for gt, that is,

knitr_tabset(y_gts, ~ cat(gt::as_raw_html(.x)))

It was actually a gt table that was causing a report to fail that inspired me to look for a better solution.

On stack overflow and related Quarto issue comment, Christophe Dervieux recommends using knit_child to create dynamic content and also mentions the knit_print function.

What is knit_print?

The related vignette for this function explains the process in great detail so I won’t say too much, but fundamentally

  • the function is a generic meaning it calls knit_print.foo for an object with class foo,

  • it is the default argument of the render chunk option called on all output.

Most of the time it is going to default to simply printing the object

getS3method('knit_print', 'default')
function (x, ..., inline = FALSE) 
{
    if (inline) 
        x
    else normal_print(x)
}
<bytecode: 0x0000000012a047f8>
<environment: namespace:knitr>
Note

normal_print is just used to use the S4 method show in place of print for S4 objects.

Example

We can intercept or overwrite this functionality as required. Suppose we define the following custom method, with specific argument names (see the vignette for more information on these).

knit_print_strsplit <- function(x, options, inline, ...) {
  for (xi in unlist(strsplit(x, ""))) print(xi)
  invisible(NULL)
}

To apply this to a single chunk we use render: !expr knit_print_strsplit and to apply this globally we define (and register) an S3 method for character objects.

knit_print.character <- knit_print_strsplit
registerS3method("knit_print", "character", knit_print.character)

When applied, any visible character object has an altered output.

```{r}
#| render: !expr knit_print_strsplit
"Hello World"
```
[1] "H"
[1] "e"
[1] "l"
[1] "l"
[1] "o"
[1] " "
[1] "W"
[1] "o"
[1] "r"
[1] "l"
[1] "d"

Which is clearly different to the default method as shown.

"Hello World"
[1] "Hello World"

printr Package

As an aside, Yihui Xie also develops the printr whose only purpose is to define several methods for this generic that can be applied to various outputs including help files called by ?lm syntax.

It is definitely worth checking out before creating one of the methods yourself.

Application to Tabsets

Since we are always dealing with lists, the obvious step for us is to define a knit_print.list method that also utilises knit_child.

Here is a simplified version of jcutils::printer_tabset, the full version attempts to allow this to work in .Rmd and Quarto files.

printer_tabset <- function(x, options, ...) {
  if (is.null(names(x))) names(x) <- seq_along(x)

  header <- ":::: {.panel-tabset}"
  footer <- "::::"

  res <- lapply(seq_along(x), function(i) {
    knitr::knit_child(
      text = c(
        "##### `r names(x)[i]`",
        "",
        "```{r}",
        "#| echo: false",
        "x[[i]]",
        "```"
      ),
      envir = environment(),
      quiet = TRUE
    )
  })

  out <- paste(c(header, res, footer), collapse = "\n\n")
  knitr::asis_output(out)
}

Here is a line-by-line explanation of what this is doing for the curious.

  • Line 2. For an unnamed list, we assign sequential names as is standard for purrr.

  • Lines 4 - 5. These are to surround the output to let Quarto know this is a tabset.

  • Lines 7 - 20. Looping over names and objects, knit_child on each element, making sure to pass the environment as to avoid a 'x' not found type of error.

  • Lines 21 - 22. Instead of printing, we return our object with the class knit_asis so knitr can put this into the markdown file unaltered by code output like [1] ....

The beauty of this method is that knit_print is also called within the child text and our method is passed to it. So we get nesting automatically!

Demonstration

In my workflow, I will include a registerS3method (usually in the setup chunk) to be applied to all lists. This can be disabled on a per-chunk basis by supplying a chunk option render !expr knitr::normal_print as required.

Using jcutils, this would be

```{r}
#| eval: false
registerS3method("knit_print", "list", jcutils::printer_tabset)
```

But for this blog, we can use

registerS3method("knit_print", "list", printer_tabset)

For more examples, including gt see the in-progress vignette at the jcutils GitHub page.

In this post though, suppose we make a nested list of visualisations of the Palmer penguins dataset.

data("penguins", package = "palmerpenguins")

# Used to create a ggplot object
gg_method <- function(.island, .x) {
  penguins %>%
    filter(.data$island == .island) %>%
    ggplot(aes(x = .data[[.x]], y = .data$body_mass_g)) +
    geom_point(na.rm = TRUE) +
    labs(y = "Body Mass (g)", x = .x)
}

# Create a tibble of all combinations, with added plot column
plot_tb <- 
  expand_grid(
    island = unique(penguins$island),
    x = c("bill_length_mm", "bill_depth_mm", "flipper_length_mm")
  ) %>%
  mutate(plot = Map(gg_method, .data$island, .data$x))

# Convert tibble to nested list using `split` twice
nested_list <-
  split(plot_tb, plot_tb$island) %>%
  map(~ split(.x, .x$x)) %>%
  modify_depth(.depth = 2, ~ .x$plot[[1]])

Here, the top top-level of the list details the island variable,

names(nested_list)
[1] "Biscoe"    "Dream"     "Torgersen"

And the second level indicates the variable to be used on the x-axis,

names(nested_list[[1]])
[1] "bill_depth_mm"     "bill_length_mm"    "flipper_length_mm"

To print a list of plots, making the object visible creates expected tabsets.

I think the real advantage comes from nested lists, which I unfortunately couldn’t get working in .Rmd files, but works fine for Quarto formats like this blog!

Even better than this is the fact that each sub-list is treated independently and so ragged lists (lists of different lengths) will also work.

Image Credit

Josh Cowley. October 25th, 2022. “Quayside Stone Sculptures, Newcastle Upon Tyne”.