diff --git a/CHANGELOG.md b/CHANGELOG.md index 699ccfa..49d2e5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Unclosed Quarto panel tabsets after a knitr chunk error no longer break later report sections. `open_tabset()` tracks depth, and `register_tabset_chunk_hooks()` closes leftover fences when a chunk fails. + ### Changed - Colocalization heatmap text now explains that proteins are selected by mean abundance (up to 40 markers), globally across samples or within each cell type. diff --git a/DEVELOPERS.md b/DEVELOPERS.md index e7c3bc4..d958e72 100644 --- a/DEVELOPERS.md +++ b/DEVELOPERS.md @@ -333,6 +333,8 @@ Tab UI is created with Quarto fenced divs: The `tabset_*` helpers write these divs for you. Tab titles come from markdown headings (`#` level set by the `level` argument). Use `level` consistently within a section so tab nesting matches the surrounding heading hierarchy (e.g. `level = 5` under a `####` markdown heading). +Report setup calls `register_tabset_chunk_hooks()`. If a knitr chunk errors after `open_tabset()` or a `tabset_*` helper has written an opening fence, the hook appends the missing `:::` so later sections still parse. In `.qmd` chunks, call `open_tabset()` instead of writing the opening fence with `cat()`. Markdown tabsets written outside R chunks are unchanged. + ### `tabset_plotlist()` Renders a **named list of plots** as a tabset — one tab per plot. @@ -409,10 +411,10 @@ Often used inside a manually opened tabset: ```r #| results: 'asis' -cat("::: {.panel-tabset .nav-pills}\n") +open_tabset() section_table(key_tables$pool, "Hash pool metrics", 3) section_table(key_tables$sample, "Sample metrics", 3) -cat(":::\n") +close_tabset() ``` ### `style_table()` diff --git a/NAMESPACE b/NAMESPACE index 17cfe5a..db2d494 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -65,6 +65,7 @@ export(merge_cell_types) export(merge_data) export(merged_cell_types) export(order_cd_markers) +export(open_tabset) export(order_sample_alias_factors) export(plot_embedding) export(plot_embeddings_samplewise) @@ -80,6 +81,7 @@ export(process_data) export(read_qc_files) export(read_samplesheet) export(register_es_data_workflow) +export(register_tabset_chunk_hooks) export(run_abundance_anova) export(run_proximity_anova) export(section_intro) diff --git a/R/plot_helpers.R b/R/plot_helpers.R index 209af8b..d95523a 100644 --- a/R/plot_helpers.R +++ b/R/plot_helpers.R @@ -5,6 +5,134 @@ plot_anchor_slug <- function(...) { gsub("^-+|-+$", "", x) } +.tabset_state <- new.env(parent = emptyenv()) +.tabset_state$depth <- 0L +.tabset_state$stack <- integer(0) + +#' Reset tracked Quarto tabset depth +#' +#' Intended for tests. Report rendering resets state when +#' [register_tabset_chunk_hooks()] is called. +#' +#' @return `NULL`, invisibly. +#' +#' @noRd +reset_tabset_state <- function() { + .tabset_state$depth <- 0L + .tabset_state$stack <- integer(0) + invisible(NULL) +} + +#' Current number of opened tabsets that have not been closed +#' +#' @return Integer depth. +#' +#' @noRd +tabset_depth <- function() { + .tabset_state$depth +} + +#' Markdown fences that close leftover tabsets down to a depth +#' +#' Updates the tracked depth. Does not print; callers that run during knitr +#' chunk evaluation should `cat()` the result, while knitr output hooks should +#' return it so it is appended to the chunk markdown. +#' +#' @param until Integer depth to restore. Tabsets opened above this depth are +#' closed. +#' +#' @return A string of `:::` fences, or `""` when nothing is unclosed. +#' +#' @noRd +unclosed_tabset_fences <- function(until = 0L) { + until <- as.integer(until) + if (length(until) != 1L || is.na(until) || until < 0L) { + until <- 0L + } + + n <- .tabset_state$depth - until + if (n <= 0L) { + return("") + } + + .tabset_state$depth <- until + paste(rep(":::", n), collapse = "\n") +} + +#' Open a Quarto panel tabset +#' +#' Emits the fenced div that starts a `{.panel-tabset}` and records it so +#' leftover tabsets can be closed if a knitr chunk errors. Prefer this over +#' writing the fence with `cat()` in `.qmd` files. +#' +#' @return The new tabset depth, invisibly. +#' +#' @export +open_tabset <- function() { + cat("::: {.panel-tabset .nav-pills}\n") + .tabset_state$depth <- .tabset_state$depth + 1L + invisible(.tabset_state$depth) +} + +#' Close Tabset +#' +#' Closes a tabset opened with [open_tabset()] or [tabset_figure_table()]. +#' Does nothing when no tracked tabset is open. +#' +#' @return The remaining tabset depth, invisibly. +#' +#' @export +close_tabset <- function() { + fence <- unclosed_tabset_fences(until = max(0L, .tabset_state$depth - 1L)) + if (nzchar(fence)) { + cat(fence, "\n", sep = "") + } + invisible(.tabset_state$depth) +} + +#' Register knitr hooks that close leftover panel tabsets +#' +#' When Quarto is run with `execute: error: true`, a chunk can fail after +#' [open_tabset()] (or a `tabset_*` helper) has written an opening fence. +#' This hook restores the tabset depth from the start of the chunk by appending +#' the missing `:::` fences so later sections of the report still parse. +#' +#' Call once during report setup (see `inst/quarto/shared/preprocessing.qmd`). +#' +#' @return `NULL`, invisibly. +#' +#' @export +register_tabset_chunk_hooks <- function() { + reset_tabset_state() + knitr::opts_chunk$set(pixelatorES_tabset_guard = TRUE) + knitr::knit_hooks$set( + pixelatorES_tabset_guard = function(before, options, envir) { + if (before) { + .tabset_state$stack <- c(.tabset_state$stack, .tabset_state$depth) + return(NULL) + } + + n_stack <- length(.tabset_state$stack) + start <- if (n_stack > 0L) { + .tabset_state$stack[[n_stack]] + } else { + 0L + } + if (n_stack > 0L) { + .tabset_state$stack <- .tabset_state$stack[-n_stack] + } + + fences <- unclosed_tabset_fences(until = start) + if (!nzchar(fences)) { + return(NULL) + } + + paste0("\n\n", fences, "\n\n") + } + ) + invisible(NULL) +} + resolve_anchor_prefix <- function(anchor_prefix = NULL) { if (is.null(anchor_prefix)) { label <- knitr::opts_current$get("label") @@ -89,13 +217,12 @@ tabset_plotlist <- prefix <- resolve_anchor_prefix(anchor_prefix) - # Start the tabset - cat("::: {.panel-tabset .nav-pills}\n") + open_tabset() + if (close) { + on.exit(close_tabset(), add = TRUE) + } title_plotlist(plots, level, anchor_prefix = prefix) - - # Close the tabset - if (close) cat(":::\n") } @@ -133,9 +260,11 @@ tabset_nested_plotlist <- prefix <- resolve_anchor_prefix(anchor_prefix) - # Start the tabset cat("\n\n") - cat("::: {.panel-tabset .nav-pills}\n") + open_tabset() + if (close) { + on.exit(close_tabset(), add = TRUE) + } for (tab in seq_along(plots)) { if (inherits(plots[[tab]], "list")) { @@ -163,15 +292,14 @@ tabset_nested_plotlist <- ) } } - # Close the tabset - if (close) cat(":::\n") } #' Tabset a figure and a table #' #' Tabset a figure and a table at a given header level. After using this -#' function, you should close the tabset with `close_tabset()`. +#' function, you should close the tabset with [close_tabset()]. If the chunk +#' errors before that call, [register_tabset_chunk_hooks()] closes the fence. #' #' @param figure A ggplot object representing the figure to be tabsetted or a list of ggplot objects to be tabsetted as #' nested tabs. @@ -194,8 +322,7 @@ tabset_figure_table <- function(figure, table, level = 2, title = title_plotlist ) - # Start the tabset - cat("::: {.panel-tabset .nav-pills}\n") + open_tabset() cat(paste0(strrep("#", level), " Figure\n\n")) @@ -211,19 +338,6 @@ tabset_figure_table <- function(figure, table, level = 2, return(table) } -#' Close Tabset -#' -#' Closes the tabset that was opened with `tabset_figure_table`. -#' -#' @return Nothing. -#' -#' @export -#' -close_tabset <- - function() { - cat(":::\n") - } - #' Create a Void Plot #' #' Creates a ggplot object with no data and a void theme. diff --git a/inst/quarto/pixelatorES.qmd b/inst/quarto/pixelatorES.qmd index 0731e72..132506f 100644 --- a/inst/quarto/pixelatorES.qmd +++ b/inst/quarto/pixelatorES.qmd @@ -22,6 +22,7 @@ params: #| include: false library(pixelatorES) +register_tabset_chunk_hooks() report <- get_es_workflow_report(params$workflow) ``` @@ -41,12 +42,12 @@ for (child in report$preamble) { #| echo: false cat("\n# \n\n") -cat("::: {.panel-tabset .nav-pills}\n\n") +open_tabset() for (section in report$sections) { # Two newlines so the heading starts a new block even when the preceding # child ends with a list or paragraph cat("\n\n## ", section$title, "\n\n", sep = "") cat(knitr::knit_child(section$child, quiet = TRUE)) } -cat("\n:::\n") +close_tabset() ``` diff --git a/inst/quarto/shared/preprocessing.qmd b/inst/quarto/shared/preprocessing.qmd index fa78db9..140e3d5 100644 --- a/inst/quarto/shared/preprocessing.qmd +++ b/inst/quarto/shared/preprocessing.qmd @@ -24,6 +24,9 @@ library(knitr) # Hook to convert PNG -> WebP after each plot is saved knit_hooks$set(plot = convert_png_to_webp) + +# Close leftover panel-tabset fences when a chunk errors +register_tabset_chunk_hooks() ``` ```{r} diff --git a/inst/quarto/workflows/amplicon_demux/quality_metrics.qmd b/inst/quarto/workflows/amplicon_demux/quality_metrics.qmd index ea0dd95..3a442ff 100644 --- a/inst/quarto/workflows/amplicon_demux/quality_metrics.qmd +++ b/inst/quarto/workflows/amplicon_demux/quality_metrics.qmd @@ -19,14 +19,14 @@ eval_standard_key_metrics <- #| label: key_metrics_pool #| eval: !expr eval_hashed_key_metrics #| results: 'asis' -cat("::: {.panel-tabset .nav-pills}\n") +open_tabset() if (!is.null(key_tables$pool)) { section_table(key_tables$pool, "Hash pool metrics", 3) } if (!is.null(key_tables$sample)) { section_table(key_tables$sample, "Sample metrics", 3) } -cat(":::\n") +close_tabset() ``` ```{r} diff --git a/man/close_tabset.Rd b/man/close_tabset.Rd index 550297d..5bff76e 100644 --- a/man/close_tabset.Rd +++ b/man/close_tabset.Rd @@ -7,8 +7,9 @@ close_tabset() } \value{ -Nothing. +The remaining tabset depth, invisibly. } \description{ -Closes the tabset that was opened with \code{tabset_figure_table}. +Closes a tabset opened with \code{\link[=open_tabset]{open_tabset()}} or \code{\link[=tabset_figure_table]{tabset_figure_table()}}. +Does nothing when no tracked tabset is open. } diff --git a/man/open_tabset.Rd b/man/open_tabset.Rd new file mode 100644 index 0000000..2a9f775 --- /dev/null +++ b/man/open_tabset.Rd @@ -0,0 +1,16 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/plot_helpers.R +\name{open_tabset} +\alias{open_tabset} +\title{Open a Quarto panel tabset} +\usage{ +open_tabset() +} +\value{ +The new tabset depth, invisibly. +} +\description{ +Emits the fenced div that starts a \code{{.panel-tabset}} and records it so +leftover tabsets can be closed if a knitr chunk errors. Prefer this over +writing the fence with \code{cat()} in \code{.qmd} files. +} diff --git a/man/register_tabset_chunk_hooks.Rd b/man/register_tabset_chunk_hooks.Rd new file mode 100644 index 0000000..7ca4122 --- /dev/null +++ b/man/register_tabset_chunk_hooks.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/plot_helpers.R +\name{register_tabset_chunk_hooks} +\alias{register_tabset_chunk_hooks} +\title{Register knitr hooks that close leftover panel tabsets} +\usage{ +register_tabset_chunk_hooks() +} +\value{ +\code{NULL}, invisibly. +} +\description{ +When Quarto is run with \code{execute: error: true}, a chunk can fail after +\code{\link[=open_tabset]{open_tabset()}} (or a \code{tabset_*} helper) has written an opening fence. +This hook restores the tabset depth from the start of the chunk by appending +the missing \verb{:::} fences so later sections of the report still parse. +} +\details{ +Call once during report setup (see \code{inst/quarto/shared/preprocessing.qmd}). +} diff --git a/man/tabset_figure_table.Rd b/man/tabset_figure_table.Rd index 32ff7b6..1a64287 100644 --- a/man/tabset_figure_table.Rd +++ b/man/tabset_figure_table.Rd @@ -21,5 +21,6 @@ A formatted tabset containing the figure and table. } \description{ Tabset a figure and a table at a given header level. After using this -function, you should close the tabset with \code{close_tabset()}. +function, you should close the tabset with \code{\link[=close_tabset]{close_tabset()}}. If the chunk +errors before that call, \code{\link[=register_tabset_chunk_hooks]{register_tabset_chunk_hooks()}} closes the fence. } diff --git a/tests/testthat/test_plot_helpers.R b/tests/testthat/test_plot_helpers.R index 0a7c49f..6a98b29 100644 --- a/tests/testthat/test_plot_helpers.R +++ b/tests/testthat/test_plot_helpers.R @@ -1,4 +1,7 @@ test_that("Tab setting and title setting work as expected", { + pixelatorES:::reset_tabset_state() + on.exit(pixelatorES:::reset_tabset_state(), add = TRUE) + g <- ggplot() + theme_void() @@ -49,6 +52,8 @@ test_that("Tab setting and title setting work as expected", { options(pixelatorES.dev_mode = TRUE) expect_error(title_plotlist(plot_list, anchor_prefix = NULL)) + expect_error(tabset_plotlist(plot_list, anchor_prefix = NULL)) + expect_equal(pixelatorES:::tabset_depth(), 0L) options(pixelatorES.dev_mode = FALSE) @@ -95,6 +100,9 @@ test_that("title_plotlist emits anchor divs when anchor_prefix is set", { }) test_that("tabset_nested_plotlist extends anchor prefixes for nested tabs", { + pixelatorES:::reset_tabset_state() + on.exit(pixelatorES:::reset_tabset_state(), add = TRUE) + g <- ggplot() + theme_void() nested_plot_list <- list( "B cell" = list("CD19" = g) @@ -111,6 +119,76 @@ test_that("tabset_nested_plotlist extends anchor prefixes for nested tabs", { expect_true(any(grepl('data-anchor-id="coloc-celltype-b-cell-cd19"', out))) }) +test_that("tabset depth is tracked and leftover fences can be flushed", { + pixelatorES:::reset_tabset_state() + on.exit(pixelatorES:::reset_tabset_state(), add = TRUE) + + expect_equal(pixelatorES:::tabset_depth(), 0L) + expect_equal( + capture.output(open_tabset()), + "::: {.panel-tabset .nav-pills}" + ) + expect_equal(pixelatorES:::tabset_depth(), 1L) + expect_equal(capture.output(close_tabset()), ":::") + expect_equal(pixelatorES:::tabset_depth(), 0L) + + capture.output(open_tabset()) + capture.output(open_tabset()) + expect_equal(pixelatorES:::tabset_depth(), 2L) + expect_equal( + pixelatorES:::unclosed_tabset_fences(until = 0L), + ":::\n:::" + ) + expect_equal(pixelatorES:::tabset_depth(), 0L) + expect_equal(capture.output(close_tabset()), character()) +}) + +test_that("tabset chunk hook closes fences left open when a chunk errors", { + pixelatorES:::reset_tabset_state() + old_opt <- knitr::opts_chunk$get("pixelatorES_tabset_guard") + old_hook <- knitr::knit_hooks$get("pixelatorES_tabset_guard") + on.exit( + { + pixelatorES:::reset_tabset_state() + knitr::opts_chunk$set(pixelatorES_tabset_guard = old_opt) + if (!is.null(old_hook)) { + knitr::knit_hooks$set(pixelatorES_tabset_guard = old_hook) + } + }, + add = TRUE + ) + + rmd <- tempfile(fileext = ".Rmd") + on.exit(unlink(rmd), add = TRUE) + + writeLines( + c( + "```{r, results='asis', error=TRUE}", + "open_tabset()", + "cat('## Inside\\n\\n')", + "stop('chunk failed after opening a tabset')", + "close_tabset()", + "```", + "", + "after the failed chunk" + ), + rmd + ) + + register_tabset_chunk_hooks() + md_file <- knitr::knit(rmd, quiet = TRUE, envir = new.env(parent = globalenv())) + on.exit(unlink(md_file), add = TRUE) + md <- paste(readLines(md_file), collapse = "\n") + + expect_match(md, "::: \\{\\.panel-tabset \\.nav-pills\\}", perl = TRUE) + expect_match(md, "after the failed chunk", perl = TRUE) + open_at <- regexpr("::: \\{\\.panel-tabset", md) + after_at <- regexpr("after the failed chunk", md) + closings <- gregexpr("(^|\\n):::(\\n|$)", md, perl = TRUE)[[1]] + expect_true(any(closings > open_at & closings < after_at)) + expect_equal(pixelatorES:::tabset_depth(), 0L) +}) + test_that("Embedding plots work as expected", { set.seed(37) seur <-