Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
b088f5c
issue #303 margin adjustment for multi-line labels and titles
vincentarelbundock Feb 13, 2026
53cbb82
update snapshot
vincentarelbundock Feb 15, 2026
d1d2cba
docs
vincentarelbundock Feb 15, 2026
fbd19f8
theme_dynamic
grantmcdermott Feb 16, 2026
109640b
get_tpar instead of par()
vincentarelbundock Feb 21, 2026
3ab1f94
Merge branch 'issue303' of https://github.com/vincentarelbundock/tiny…
grantmcdermott Feb 22, 2026
ebf44db
tests
grantmcdermott Feb 22, 2026
9bbcff9
Merge branch 'main' into pr/vincentarelbundock/549
grantmcdermott Apr 20, 2026
03c07ff
Refactor dynamic margin logic to additive build
grantmcdermott Apr 21, 2026
7cf3082
Anchor main title at consistent line under dynmar
grantmcdermott Apr 21, 2026
096097b
Gotcha: treat sub=NA as absent when positioning main
grantmcdermott Apr 21, 2026
7ff977d
Anchor multi-line main at line N, not center
grantmcdermott Apr 21, 2026
d472f53
Scale main title bump with multi-line sub
grantmcdermott Apr 21, 2026
daae773
Replace hardcoded 1.2 sub-row bump with cex_sub + 0.2
grantmcdermott Apr 21, 2026
f0919ae
Remove internal pad from dynmar_side(); use theme mar as baseline
grantmcdermott Apr 21, 2026
4e9a9b0
Bump top outer margin by facet-strip height under dynmar
grantmcdermott Apr 22, 2026
eb50612
Add 0.6-line baseline padding to dynamic theme mar[4]
grantmcdermott Apr 22, 2026
ac711ab
Compute dynmar margins up front; drop theme baseline on outer-legend …
grantmcdermott Apr 22, 2026
e55b771
known false positives for test suite on macos
grantmcdermott Apr 27, 2026
cadbc4c
fix mfrow + plot.new clash
grantmcdermott Apr 27, 2026
11e2406
update test snapshots
grantmcdermott Apr 27, 2026
fd84857
avoid duplicate sanitize_legend calls
grantmcdermott Apr 27, 2026
853135f
remove unreachable dynmar_side fallbacks
grantmcdermott Apr 27, 2026
c8fe8ad
fix r cmd check errors
grantmcdermott Apr 27, 2026
9e3abfb
test tweaks
grantmcdermott Apr 27, 2026
8c385e8
issue #479
grantmcdermott Apr 28, 2026
55f9afb
update test snapshots
grantmcdermott Apr 28, 2026
d0c4652
news and dev version bump
grantmcdermott Apr 28, 2026
cd06059
issue #574
grantmcdermott Apr 28, 2026
fc63283
scalar comments for provenance
grantmcdermott Apr 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,12 @@ Tests use `tinytest` + `tinysnapshot`. Snapshot tests produce SVG output with Li
2. Command Palette → "Dev Containers: Reopen in Container"
3. Dependencies install automatically

Non-snapshot tests (logical assertions, error checks, etc.) run fine on any platform. Even with the devcontainer, a small number of snapshot tests (~2-3) may produce false positive failures on macOS hosts due to imperceptible rendering differences. These show up in `inst/tinytest/_tinysnapshot_review/` but the visual differences are too small to detect by eye. This is a known quirk — don't worry about these specific persistent failures. However, if you see more than ~3 snapshot failures, something real is likely broken and needs investigation.
Non-snapshot tests (logical assertions, error checks, etc.) run fine on any platform. Even with the devcontainer, a small number of snapshot tests (~2-3) may produce false positive failures on macOS hosts due to imperceptible rendering differences. These show up in `inst/tinytest/_tinysnapshot_review/` but the visual differences are too small to detect by eye. Known false positives:

- `xaxl_yaxl`
- `palette_manual_continuous`

This is a known quirk — don't worry about these specific persistent failures. However, if you see more than ~3 snapshot failures, something real is likely broken and needs investigation.

### Running Tests
```bash
Expand Down
2 changes: 1 addition & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Package: tinyplot
Type: Package
Title: Lightweight Extension of the Base R Graphics System
Version: 0.6.1
Version: 0.6.1.99
Date: 2026-03-28
Authors@R:
c(
Expand Down
29 changes: 29 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,35 @@ _If you are viewing this file on CRAN, please check the
[latest NEWS](https://grantmcdermott.com/tinyplot/NEWS.html) on our website
where the formatting is also better._

## Development

### Aesthetic changes

- **Dynamic themes**. We have significantly refactored how our _dynamic_ themes
work. Recall, these are themes like `"dynamic"`, `"clean"`, `"bw"`, etc. that
automatically adjust margin spacing and other elements to reduce whitespace.
Our refactoring and internal changes have some user-facing implications,
insofar as they can affect the appearance of your plots. Technically, these
are "breaking" aesthetic changes---since your plot might look slightly
different from before---but we hope that you will agree that these are clear
improvements. (#549 @grantmcdermott, @vincentarelbundock)

- Plot margins now correctly respond to missing and/or multi-line `main`,
`sub`, and `x`/`y` axis titles. For example, a plot with a `main` (or `sub`)
title will expand to the top of the device region to reduce excess
whitespace. (#303)
- Left-justified `main` and `sub` titles now correctly anchor to the y-axis
line, even when long horizontal tick labels widen the left margin. (#479)
- Similarly, center-justified axis titles are now anchored on the relevant
axis alone, rather than the full plot region. (#573)
- `cex.xlab` and `cex.ylab` now correctly control axis title size. The
more general `cex.lab` is still respected as a fallback. (#574)

### New features

- New `"dynamic"` theme that now serves as the foundation for all other dynamic
(tiny)themes. (#549 @grantmcdermott)

## v0.6.1

### Aesthetic changes
Expand Down
36 changes: 32 additions & 4 deletions R/facet.R
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,15 @@ draw_facet_window = function(
draw,
grid,
has_legend,
main,
sub,
type,
xlab,
x, xmax, xmin,
ylab,
y, ymax, ymin,
tpars = NULL
tpars = NULL,
dynmar_computed = NULL
) {

if (is.null(tpars)) tpars = tpar()
Expand Down Expand Up @@ -114,6 +119,23 @@ draw_facet_window = function(

## Dynamic plot margin adjustments
if (dynmar) {
# Margins were pre-computed in tinyplot.default (dynmar_computed).
# Use that as the base instead of par("mar") which may have been
# reset by the before.plot.new hook.
side.sub = get_tpar("side.sub", tpar_list = tpars, default = 3)
omar = dynmar_computed
# Under facets, main/sub sit ABOVE the top facet strip. Bump the
# outer top margin by the strip height so sub doesn't collide with
# the strip. fmar[3] already captures facet_newlines and the
# facet_grid adjustment; add back the 0.5 line that was stripped
# when frame.plot is FALSE (that reduction is meant to tighten
# inter-panel gaps, not the top strip).
strip_bump = fmar[3]
if (isFALSE(frame.plot) && !isTRUE(facet.args[["free"]])) {
strip_bump = strip_bump + 0.5
}
omar[3] = omar[3] + strip_bump

if (par("las") %in% 1:2) {
# extra whitespace bump on the y axis
## overrides for ridge and some types that use integer spacing with (named) axis labels ## FXIME
Expand Down Expand Up @@ -155,6 +177,9 @@ draw_facet_window = function(
fmar[1] = fmar[1] - (whtsbp * cex_fct_adj)
}
}

if (type == "spineplot") omar[4] = 2.1 # FIXME catch for spineplot RHS axis labs

# FIXME: Is this causing issues for lhs legends with facet_grid?
# catch for missing rhs legend
if (isTRUE(attr(facet, "facet_grid")) && !has_legend) {
Expand Down Expand Up @@ -183,9 +208,11 @@ draw_facet_window = function(
# on our earlier calculations.
par(mfrow = c(nfacet_rows, nfacet_cols))
} else if (dynmar) {
# Dynamic plot margin adjustments
omar = par("mar")
omar = omar - c(0, 0, 1, 0) # reduce top whitespace since no facet (title)
# Dynamic plot margin adjustments (no facets). Margins were pre-computed
# in tinyplot.default and passed via dynmar_computed; use them directly.
# Tick-label *width/height* (whtsbp) is added further below.
side.sub = get_tpar("side.sub", tpar_list = tpars, default = 3)
omar = dynmar_computed
if (type == "spineplot") omar[4] = 2.1 # FIXME catch for spineplot RHS axis labs
if (par("las") %in% 1:2) {
# extra whitespace bump on the y axis
Expand Down Expand Up @@ -218,6 +245,7 @@ draw_facet_window = function(
omar[1] = omar[1] + whtsbp
}
}

par(mar = omar)
}

Expand Down
8 changes: 6 additions & 2 deletions R/legend.R
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ legend_outer_margins = function(legend_env, apply = TRUE) {
if (legend_env$dynmar) {
omar = par("mar")
if (legend_env$outer_bottom) {
omar[1] = theme_clean$mgp[1] + 1 * par("cex.lab")
omar[1] = theme_dynamic$mgp[1] + 1 * par("cex.lab")
if (legend_env$has_sub && (is.null(.tpar[["side.sub"]]) || .tpar[["side.sub"]] == 1)) {
omar[1] = omar[1] + 1 * par("cex.sub")
}
Expand Down Expand Up @@ -407,7 +407,7 @@ prepare_legend = function(settings) {
}

legend_draw_flag = (is.null(legend) || !is.character(legend) || legend != "none" || bubble) && !isTRUE(add)
has_sub = !is.null(sub)
has_sub = text_line_count(sub) > 0L

# Generate labels for discrete legends
if (legend_draw_flag && isFALSE(by_continuous) && (!bubble || multi_legend)) {
Expand Down Expand Up @@ -487,6 +487,10 @@ build_legend_args = function(
# Configuration
gradient
) {
# Ensure legend_args[["x"]] is populated. When called from the main
# tinyplot pipeline, this is a no-op (sanitize_legend short-circuits on
# the is.null guard); when called standalone via the public draw_legend
# entry point, this normalizes the input.
legend_args = sanitize_legend(legend, legend_args)

# Set defaults
Expand Down
9 changes: 6 additions & 3 deletions R/legend_multi.R
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,6 @@ prepare_legend_multi = function(settings) {
)
)

legend_args = sanitize_legend(legend, legend_args)

# Legend for grouping variable (by)
lgby = list(
legend_args = modifyList(
Expand Down Expand Up @@ -162,10 +160,15 @@ draw_multi_legend = function(
sub_positions = c("bottomleft!", "topleft!")
}

# Assign positions of individual legends
# Assign positions of individual legends. Re-sanitize so the new per-legend
# position string ("bottomright!" etc.) populates legend_args[["x"]], which
# downstream code (e.g. build_legend_args) reads without further normalization.
for (ll in seq_along(legend_list)) {
legend_list[[ll]][["legend"]] = sub_positions[ll]
legend_list[[ll]][["legend_args"]][["x"]] = NULL
legend_list[[ll]][["legend_args"]] = sanitize_legend(
sub_positions[ll], legend_list[[ll]][["legend_args"]]
)
}

#
Expand Down
147 changes: 143 additions & 4 deletions R/tinyplot.R
Original file line number Diff line number Diff line change
Expand Up @@ -833,6 +833,17 @@ tinyplot.default = function(
} else {
settings$legend_args = list(x = NULL)
}
# normalize legend position up front so downstream code can read
# legend_args[["x"]] directly (idempotent: guarded inside sanitize_legend).
# Use settings$legend (captured via substitute()) rather than the raw
# `legend` promise, since the latter may be an unevaluated call like
# `legend("bottom!", ...)` that would error if forced by is.null() etc.
# Skip when add=TRUE: no new legend is drawn in add-mode, and
# settings$legend is coerced to FALSE which sanitize_legend would
# spuriously normalize to the "right!" default.
if (!isTRUE(add)) {
settings$legend_args = sanitize_legend(settings$legend, settings$legend_args)
}

# alias: bg = fill
if (is.null(bg) && !is.null(fill)) settings$bg = fill
Expand Down Expand Up @@ -938,6 +949,115 @@ tinyplot.default = function(

env2env(settings, environment())

#
## dynmar: compute margins up front -----
#
# Under dynmar themes, compute the full margin once before any drawing.
# theme_mar (from the theme) is the baseline padding; dynmar_side() adds
# space for ticks, axis labels, main, and sub. whtsbp adds tick-label
# width/height for horizontal y-labels or vertical x-labels.
# For outer legends ("bottom!", "left!", etc.), skip dynmar_side on the
# legend's side — the legend code owns that side's mar via oma.
#
dynmar_computed = NULL
.whtsbp = c(0, 0, 0, 0)
if (!add && isTRUE(get_tpar("dynmar"))) {
.side.sub = get_tpar("side.sub", default = 3)
# Read the theme's intended mar. Also build a tpars list from the theme
# definition so dynmar_side uses theme mgp/tcl/las (which aren't in
# par() yet since the before.plot.new hook hasn't fired).
.tinytheme = get_tpar("tinytheme", default = "default")
.theme_def = if (!is.null(.tinytheme) && .tinytheme != "default") {
get(paste0("theme_", .tinytheme), envir = asNamespace("tinyplot"))
} else NULL
.theme_mar = if (!is.null(.theme_def[["mar"]])) .theme_def[["mar"]] else par("mar")
.tpars = if (!is.null(.theme_def)) .theme_def else tpar()
# Merge pending before.plot.new hook values into .tpars so user
# overrides passed via tinytheme(..., las = 2) (or tpar(...)) are
# visible to dynmar_side()/whtsbp before plot.new fires. Without this,
# user overrides for par-level values (las, cex.lab, mgp, tcl, etc.)
# are queued in hook closures and unreachable from par() at this point.
.pending_hooks = get_environment_variable(".tpar_hooks")
for (.h in .pending_hooks) {
.bp = environment(.h)[["base_par"]]
if (is.list(.bp)) .tpars = modifyList(.tpars, .bp)
}

# Detect outer-legend sides (order: bottom, left, top, right).
.lgnd_pos = settings$legend_args[["x"]]
.outer_sides = c(
grepl("bottom!$", .lgnd_pos),
grepl("left!$", .lgnd_pos),
grepl("top!$", .lgnd_pos),
grepl("right!$", .lgnd_pos)
)

.dyn = c(
dynmar_side(1, xlab, main = main, sub = sub, side.sub = .side.sub,
axis_on = !identical(xaxt, "none") && !identical(xaxt, "n"),
tpars = .tpars),
dynmar_side(2, ylab,
axis_on = !identical(yaxt, "none") && !identical(yaxt, "n"),
tpars = .tpars),
dynmar_side(3, NULL, main = main, sub = sub, side.sub = .side.sub,
tpars = .tpars),
dynmar_side(4, NULL, tpars = .tpars)
)
# Drop the theme's baseline padding on outer-legend sides so the plot
# region meets the legend's oma flush. Only .theme_mar is zeroed — the
# axis-driven bumps in .dyn (tick rows, axis labels, main/sub) are kept
# so that "left!" and "bottom!" legends don't collide with axis content.
.theme_mar[.outer_sides] = 0

# whtsbp uses strwidth(units="figure") + grconvertX("nfc" → "lines"),
# both of which give device-default font metrics without requiring
# plot.new()/plot.window() first. A preparatory plot.new() here would
# advance par("mfg") (breaking mfrow layouts) and create a blank page
# in IDE plot panes (Positron). Left/bottom/top margin sizing for
# title alignment is handled later by draw_legend or the no-legend
# path's own plot.new(), after which the margins are reinstated
# via dynmar_computed + .whtsbp before draw_title runs.

# Compute whtsbp (tick-label width/height bump). Read `las` from .tpars
# (the theme definition) rather than par() — par("las") isn't set to the
# theme's intended value until the before.plot.new hook fires, but this
# block runs before that.
.whtsbp = c(0, 0, 0, 0)
.las = get_tpar("las", tpar_list = .tpars, default = par("las"))
if (.las %in% 1:2) {
if (type == "ridge") {
yaxlabs = levels(y)
} else if (!is.null(ylabs)) {
yaxlabs = if (!is.null(names(ylabs))) names(ylabs) else ylabs
} else if (type == "boxplot" && isTRUE(flip) && !is.null(xlabs)) {
yaxlabs = if (!is.null(names(xlabs))) names(xlabs) else xlabs
} else {
yaxlabs = axisTicks(usr = extendrange(ylim, f = 0.04), log = par("ylog"))
}
if (!is.null(yaxl)) yaxlabs = tinylabel(yaxlabs, yaxl)
whtsbp_y = grconvertX(max(strwidth(yaxlabs, "figure")), from = "nfc", to = "lines") -
grconvertX(0, from = "nfc", to = "lines") - 1
if (is.finite(whtsbp_y) && whtsbp_y > 0) .whtsbp[2] = whtsbp_y
}
if (.las %in% 2:3) {
xaxlabs = if (is.null(xlabs)) axisTicks(usr = extendrange(xlim, f = 0.04), log = par("xlog")) else
if (!is.null(names(xlabs))) names(xlabs) else xlabs
if (!is.null(xaxl)) xaxlabs = tinylabel(xaxlabs, xaxl)
whtsbp_x = grconvertX(max(strwidth(xaxlabs, "figure")), from = "nfc", to = "lines") - 1
if (is.finite(whtsbp_x) && whtsbp_x > 0) .whtsbp[1] = whtsbp_x
}

# Under facets, per-facet tick labels render smaller (scaled by
# cex_fct_adj), so whtsbp — which is computed from device font metrics
# — needs the same scaling to match the actual rendered margin used by
# draw_facet_window. Without this, draw_title's mar reserves too much
# space on the LHS and anchors the title too far right.
if (cex_fct_adj != 1) .whtsbp = .whtsbp * cex_fct_adj

dynmar_computed = .theme_mar + .dyn
par(mar = dynmar_computed + .whtsbp)
}

if (legend_draw_flag) {
if (!multi_legend) {
## simple case: single legend only
Expand Down Expand Up @@ -988,7 +1108,17 @@ tinyplot.default = function(
#

if (!add) {
draw_title(main, sub, xlab, ylab, legend, legend_args, opar)
# Reinstate dynmar margins and user coordinates after draw_legend
# (which may have called plot.new and reset par via hooks).
if (!is.null(dynmar_computed)) {
par(mar = dynmar_computed + .whtsbp)
if (!is.null(xlim) && !is.null(ylim)) {
plot.window(xlim = xlim, ylim = ylim)
}
}
draw_title(main, sub, xlab, ylab, legend, legend_args, opar,
xlab_line_offset = if (!is.null(dynmar_computed)) .whtsbp[1] else 0,
ylab_line_offset = if (!is.null(dynmar_computed)) .whtsbp[2] else 0)
}


Expand Down Expand Up @@ -1062,10 +1192,15 @@ tinyplot.default = function(
draw = draw,
grid = grid,
has_legend = has_legend,
main = main,
sub = sub,
type = type,
xlab = xlab,
x = x, xmax = xmax, xmin = xmin,
ylab = ylab,
y = y, ymax = ymax, ymin = ymin,
tpars = tpars
tpars = tpars,
dynmar_computed = dynmar_computed
),
list = list(
add = add,
Expand All @@ -1086,16 +1221,20 @@ tinyplot.default = function(
draw = draw,
grid = grid,
has_legend = has_legend,
main = main,
sub = sub,
type = type,
xlab = xlab,
x = datapoints$x, xmax = datapoints$xmax, xmin = datapoints$xmin,
ylab = ylab,
y = datapoints$y, ymax = datapoints$ymax, ymin = datapoints$ymin,
tpars = tpar() # https://github.com/grantmcdermott/tinyplot/issues/474
tpars = tpar(), # https://github.com/grantmcdermott/tinyplot/issues/474
dynmar_computed = dynmar_computed
),
getNamespace("tinyplot")
)
list2env(facet_window_args, environment())


#
## split and draw datapoints -----
#
Expand Down
Loading
Loading