Skip to content

Rework dynamic theme logic#549

Open
vincentarelbundock wants to merge 30 commits intograntmcdermott:mainfrom
vincentarelbundock:issue303
Open

Rework dynamic theme logic#549
vincentarelbundock wants to merge 30 commits intograntmcdermott:mainfrom
vincentarelbundock:issue303

Conversation

@vincentarelbundock
Copy link
Copy Markdown
Collaborator

@vincentarelbundock vincentarelbundock commented Feb 13, 2026

GM edits:

Closes #303
Closes #479
Closes #573
Closes #574


This change fixes dynamic margins when annotation text is missing (NA, NULL, or empty) and when labels contain multiple lines.

pkgload::load_all()
tinytheme("clean")

set.seed(1)
x <- 1:20
y <- x + rnorm(length(x), sd = 2)

op <- par(mfrow = c(2, 2))
on.exit(par(op), add = TRUE)
on.exit(tinytheme(), add = TRUE)

tinyplot(x, y, main = "xlab = NA", xlab = NA, ylab = "Y")
tinyplot(x, y, main = "xlab present", xlab = "X", ylab = "Y")
tinyplot(x, y,
  main = "Multi-line labels",
  xlab = "X line 1\nX line 2", ylab = "Y line 1\nY line 2")
tinyplot(x, y, main = "Multi-line\nmain title", xlab = "X", ylab = "Y")

margins_multiline_2x2

Internal changes:

  • Added text_line_count() to normalize line counting for labels/subtitles (treat missing text as 0 lines).
  • Passed main/sub/xlab/ylab into facet margin logic and adjusted margins by line count (shrink for missing text, expand for multi-line text).
  • Used the same line-counting logic for sub in legend layout, so sub = NA no longer reserves extra bottom space.

Testing note:

  • Added/updated margin snapshots for missing and multi-line labels (including a 2x2 case).
  • Regenerated ridge snapshots because previous renders showed extra empty space at the top; the updated margin logic changed panel placement.

@grantmcdermott
Copy link
Copy Markdown
Owner

Super, thanks for taking this on. I kept meaning to, but couldn't find the time...

@vincentarelbundock vincentarelbundock changed the title [WIP] Issue #303 margin adjustment for multi-line labels and titles Issue #303 margin adjustment for multi-line labels and titles Feb 15, 2026
@vincentarelbundock vincentarelbundock marked this pull request as ready for review February 15, 2026 13:21
@vincentarelbundock
Copy link
Copy Markdown
Collaborator Author

Frankly, I'm not sure I can wrap my head entirely around the margins part of the code. But this seems to be working for the cases I investigated.

So I'd say this is OK for review.

Copy link
Copy Markdown
Owner

@grantmcdermott grantmcdermott left a comment

Choose a reason for hiding this comment

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

Left a few quick comments while I had a sec.

I also have a slightly more involved refactor in mind for the core dynamic theme margin adjustment logic. tl;dr Right now, we do things a bit back to front; we assign title space up front and then remove it if it is absent, or increase if it has multiple lines per your PR here. I think we could simplify things for these dnymaic themes, by setting margins that assume no titles a priori and then make (multline) space for them if these titles are present. I'll take a crack at this later today, hopefully.

Comment thread R/title.R Outdated
Comment thread R/title.R Outdated
Comment thread R/facet.R Outdated
@grantmcdermott
Copy link
Copy Markdown
Owner

Thanks for this. I'm completely snowed ATM and didn't get around to implementing the full margin-logic refactor that I mentioned above, i.e.

Right now, we do things a bit back to front; we assign title space up front and then remove it if it is absent, or increase if it has multiple lines per your PR here. I think we could simplify things for these dnymaic themes, by setting margins that assume no titles a priori and then make (multline) space for them if these titles are present.

But... I did at least implement a "dynamic" theme, which is something I've want to do for a while. As the name suggests, this is just a "dynamic" version of the base theme and, I think, a more intuitive foundation for the other dynamic themes to build off. (As opposed to our current logic, where the dynamic themes all build off of the "clean" theme). It's a minor change, but just flagging in case something goes wrong and we need to revert once I push to your branch here.

@vincentarelbundock
Copy link
Copy Markdown
Collaborator Author

I'm not in a rush at all, and TBH I don't understand this code super well. Probably best to wait.

@grantmcdermott
Copy link
Copy Markdown
Owner

Hey bud, I'm going to submit a 0.6.1 release to CRAN shortly, which contains mostly bug fixes.

I'll take another look at this PR afterwards, since I'd like to pair it with #500 as part of some "breaking" aesthetic changes for 0.7.0.

Instead of starting with a pre-inflated mar and subtracting when titles
are absent, dynamic themes now start at c(0.1, 0.1, 0.1, 0.1) and build
up per-side margins additively for the elements that are actually
present (tick row, axis labels, main, sub).

Key changes:

- New helper dynmar_side() in R/utils.R is the single source of truth
  for 'how much margin does this side need'. Both the pre-title path in
  tinyplot.default() and draw_facet_window() call into it.

- theme_dynamic$mar reduced to the minimal pad baseline. All dynamic
  derivatives (clean, clean2, bw, classic, minimal, ipsum, dark, ridge,
  ridge2) inherit via modifyList.

- draw_facet_window() simplified: no more 'if missing, subtract 1' per
  side; the new formula takes max(tick_extent, label_extent) because
  tick labels and axis titles share vertical space.

- Pre-sizing margins and calling plot.window() before draw_title()
  ensures title/mtext alignment measures against the plot region (not
  the default [0,1,0,1] figure region).

- draw_title() refactored: removed post-hoc ylab/main compensators that
  were workarounds for the old undersized-margin regime. Added explicit
  line = mgp[1] + (N-1)*cex for multi-line xlab so line 1 lands where a
  single-line xlab would sit (rather than being pushed up into the
  tick-label zone by base R's default multi-line layout).

- line_sub default tightened from 1.7 to 0.7 (side.sub=3) to reduce the
  gap between sub and the plot box top.

- xaxt / yaxt = 'n' is now honored: no tick row reserved when axes are
  off under a dynamic theme.

Non-dynmar themes (default, basic, tufte, void) are unchanged.

Snapshots will need regeneration on Linux (devcontainer) since the
pixel output has shifted for every dynamic theme.
Previously, main was drawn at base R's default line when sub was absent,
and at a tinyplot-specified line when sub was present. The two differed
slightly, causing main to shift when sub was toggled on/off.

Now we explicitly set line_main = mgp[3] + 0.6 for dynmar themes
regardless of sub presence. The sub branch still bumps main up by 1.2
lines when needed. Non-dynmar themes retain base R's default.
!is.null(NA) is TRUE, so the 'push main up to make room for sub' branch
was firing for sub = NA even though no sub space was reserved. Use
text_line_count(sub) > 0 so NA/empty sub is treated the same as NULL.
title() stacks multi-line main above the passed line (line N sits at
line, earlier lines extend upward). Dropping the old -(N-1)/2
adjustment, which was centering the block and causing line 1 to drift
with N. The top margin reservation already accounts for (N-1)*cex_main
extra lines.
The sub-row offset applied to line_main was a hardcoded +1.2
regardless of sub line count, so main overlapped multi-line sub. Now
scales as 1.2 + (N-1)*cex.sub, mirroring the margin reservation in
dynmar_side().
The main-push-up and margin-reservation formulas for sub both used a
hardcoded +1.2 for the first sub row, which happens to equal the
default cex.sub but doesn't scale if the user customises it. Express
the first-row bump as cex_sub + 0.2 (0.2-line breathing room) so it
tracks user-supplied cex.sub. Also fix the get_tpar default for
cex.sub in dynmar_side() to match the 1.2 used in draw_title().

No visual change for default cex.sub = 1.
Previously dynmar_side() smuggled in a pad = 0.3 constant as baseline
breathing room, which conflicted with the theme's own mar values. Now
the theme's mar acts as the baseline (via pmax), and dynmar_side()
returns exactly what's needed for the elements actually present.

Also:
- Main contribution now scales the top-line ascender with cex_main
  (0.6 * cex_main) so custom cex.main values (e.g. 2) don't clip.
- Sub contribution adds its own ascender when drawn alone (no main),
  derived from 0.6 * cex_sub. When main is present, main's ascender
  already covers the top so sub just contributes its row heights.
- Comments updated; call sites in tinyplot.R and facet.R use
  pmax(theme_mar, computed) as documented.
Without this, main/sub titles drawn above the top facet strip would
overlap the strip contents. fmar[3] already encodes facet_newlines and
the facet_grid special-case; we add back the 0.5 line that's stripped
when frame.plot is FALSE (that reduction is for inter-panel gaps, not
the top strip itself).
The right side never gets tick/label content (except spineplot), so
theme mar is the sole source of padding there. Bumping from 0.1 to
0.6 restores the breathing room the pre-refactor layout had. Other
sides stay at 0.1 (they get ample space from tick rows and labels
when present).
…sides

- Move full dynmar margin computation (theme_mar + dynmar_side + whtsbp)
  into tinyplot.default before legend drawing, pass dynmar_computed through
  to draw_facet_window instead of recomputing in two places.
- On outer-legend sides, zero only the theme baseline padding (.theme_mar)
  so the plot meets the legend's oma flush, while preserving axis-driven
  bumps (.dyn) for left!/bottom! legends.
- Detect legend position via tryCatch(eval(legend)) since sanitize_legend
  runs later inside draw_legend; default NULL legend treated as 'right!'.
- Hoist .whtsbp default so post-block references are always defined.
- draw_title gains ylab_line_offset for whtsbp-aware ylab placement.
- theme_dynamic baseline mar changed to c(0.1, 0.1, 0.6, 0.6).
@grantmcdermott grantmcdermott changed the title Issue #303 margin adjustment for multi-line labels and titles Rework dynamic theme logic Apr 28, 2026
@grantmcdermott
Copy link
Copy Markdown
Owner

grantmcdermott commented Apr 28, 2026

I've co-opted this PR to push through a larger refactoring of the dynamic theme (margins) logic that I've been meaning to implement for a while.

Specifically, I've inverted the dynamic themes' margin (dynmar) logic, so that these themes start with de minus margin that assumes no titles or labels to begin with, and then additively builds up margin space for elements that are actually present (e.g., titles and whatnot). This "inside-out" logic replaces replaces the old "outside-in" logic which worked in the opposite way (i.e, start with some arbitrarily high margin and subtract for missing elements). The latter approach required a bunch of ad hoc guards (e.g., to protect against subtracting too much space off of a fine margin) and guesswork. I've long been convinced that inverting this logic would provide a more principled/safer approach, so I'm glad to have finally done it (with help from Claude). This also made it possible to address some other long-standing dynamic theme issues.

Here's my + Claude's summary of all of the changes:

  • Margins adapt to content presence. xlab = NA / missing main / absent sub no longer leave vestigial whitespace; multi-line labels ("\n") are sized correctly. This builds on the work that @vincentarelbundock originally started with this PR, extended to more cases. (See Themes dynamic margins could be smarter about titles #303)
  • Title alignment tracks the y-axis. Previously, long y-tick labels (e.g., month names) inflated mar[2] but main/sub stayed stuck at the uninflated plot-region-left. They now anchor to the actual y-axis line, which is more aesthetically pleasing IMO and matches ggplot's behavior. (See Should main and sub positions adjust in response to themes with dynamic LHS margin? #479)
  • las is honored when reserving tick-label space. Themes that set las = 1 (e.g., theme_clean) previously had .whtsbp silently skipped because par("las") reads 0 before the theme's before.plot.new hook fires. Now read from .tpars (with pending-hook merge) so the margin calculation sees the theme's intent.
  • xlab shifts down for rotated x-tick labels. Under las = 2/3, draw_title now offsets the xlab line position by .whtsbp[1] so it clears the vertical tick-label column.
  • Faceted whtsbp is scaled by cex_fct_adj. Faceted plots render per-facet tick labels smaller; the global whtsbp now scales to match so draw_title anchors track the actual facet y-axis.
  • "dynamic" tinytheme The new theme = "dynamic" now serves as the basis for all other dynamic tinythemes.

Some internal cleanups:

  • sanitize_legend() lifted to a single up-front call in tinyplot.default. Downstream code reads legend_args[["x"]] directly instead of re-parsing the raw legend argument. Removed now-redundant calls in build_legend_args and prepare_legend_multi. Single source of the "right!" default.
  • Unreachable dynmar_side() fallbacks removed from draw_facet_window (–38 lines). add = TRUE early-returns, so dynmar_computed is guaranteed non-NULL whenever the dynmar branch runs.
  • .tpars in the dynmar block merges pending before.plot.new hook values, making user overrides (e.g., tinytheme(..., las = 2)) visible synchronously to dynmar_side() before plot.new() fires.

MWE

That's a lot of text. Seeing is believing, so here are a couple snapshot diffs that demonstrate some of these ideas:

@grantmcdermott
Copy link
Copy Markdown
Owner

grantmcdermott commented Apr 28, 2026

@vincentarelbundock @zeileis Please give this a test if you get a chance. The basic idea is that dynamic themes should give better consistency and reduce whitespace where possible. I'd like to merge this before we tackle any of the other open PRs.

@vincentarelbundock
Copy link
Copy Markdown
Collaborator Author

This seems like an excellent change, congrats @grantmcdermott !

The screenshots look fantastic.

@grantmcdermott
Copy link
Copy Markdown
Owner

@zeileis no pressure either way, but LMK if you'd like time to review this, or are happy for me to merge.

@zeileis
Copy link
Copy Markdown
Collaborator

zeileis commented May 2, 2026

Sorry for joining in so late. I didn't have much time to play around with this but had a look whether this version improves on a problem I noticed with the current CRAN version last week. Namely certain expressions in the axis labels are not rendered correctly:

tinytheme("clean2")
tinyplot(1:10, ylab = expression(Precipitation~"["~mm^{1/2}~"]"))
tinymargins1

And it took me a while to figure out that I could only address this with setting mar in the tinytheme() along with dynmar = FALSE as below. Doing it with tpar() was ineffective. E.g., this works:

tinytheme("clean2", mar = c(3.5, 3.7, 0.5, 0.5), dynmar = FALSE)
tinyplot(1:10, ylab = expression(Precipitation~"["~mm^{1/2}~"]"))

Additionally, I noticed that in the development version of this PR, setting dynmar = FALSE by itself appears to fall back to zero margins which I found rather unintuitive:

tinytheme("clean2", dynmar = FALSE)
tinyplot(1:10, ylab = expression(Precipitation~"["~mm^{1/2}~"]"))
tinymargins2

The same happens when using tpar() after tinytheme().

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants