#' @title Lightweight extension of the base R plotting function
#'
#' @description
#' Enhances the base \code{\link[graphics]{plot}} function. Supported features
#' include automatic legends and facets for grouped data, additional plot types,
#' theme customization, and so on. Users can call either `tinyplot()`, or its
#' shorthand alias `plt()`.
#'
#' @md
#' @param x,y the x and y arguments provide the x and y coordinates for the
#'   plot. Any reasonable way of defining the coordinates is acceptable; most
#'   likely the names of existing vectors or columns of data frames. See the
#'   'Examples' section below, or the function
#'   \code{\link[grDevices]{xy.coords}} for details. If supplied separately, `x`
#'   and `y` must be of the same length.
#' @param xmin,xmax,ymin,ymax minimum and maximum coordinates of relevant area
#'   or interval plot types. Only used when the `type` argument is one of
#'   `"rect"` or `"segments"` (where all four min-max coordinates are required),
#'   or `"pointrange"`, `"errorbar"`, or `"ribbon"` (where only `ymin` and
#'   `ymax` required alongside `x`). Can be passed as unquoted (NSE) variable
#'   names when called from the `tinyplot.formula` method, e.g. `ymin = varname`
#'   where `varname` is a variable in `data`.
#' @param by grouping variable(s). The default behaviour is for groups to be
#'   represented in the form of distinct colours, which will also trigger an
#'   automatic legend. (See `legend` below for customization options.) However,
#'   groups can also be presented through other plot parameters (e.g., `pch`,
#'   `lty`, or `cex`) by passing an appropriate `"by"` keyword; see Examples.
#'   Note that continuous (i.e., gradient) colour legends are also supported if
#'   the user passes a numeric or integer to `by`. To group by multiple
#'   variables, wrap them with \code{\link[base]{interaction}}.
#' @param facet the faceting variable(s) that you want arrange separate plot
#'   windows by. Can be specified in various ways:
#'   - In "atomic" form, e.g. `facet = fvar`. To facet by multiple variables in
#'   atomic form, simply interact them, e.g.
#'   `interaction(fvar1, fvar2)` or `factor(fvar1):factor(fvar2)`.
#'   - As a one-sided formula, e.g. `facet = ~fvar`. Multiple variables can be
#'   specified in the formula RHS, e.g. `~fvar1 + fvar2` or `~fvar1:fvar2`. Note
#'   that these multi-variable cases are _all_ treated equivalently and
#'   converted to `interaction(fvar1, fvar2, ...)` internally. (No distinction
#'   is made between different types of binary operators, for example, and so
#'   `f1+f2` is treated the same as `f1:f2`, is treated the same as `f1*f2`,
#'   etc.)
#'   - As a two-side formula, e.g. `facet = fvar1 ~ fvar2`. In this case, the
#'   facet windows are arranged in a fixed grid layout, with the formula LHS
#'   defining the facet rows and the RHS defining the facet columns. At present
#'   only single variables on each side of the formula are well supported. (We
#'   don't recommend trying to use multiple variables on either the LHS or RHS
#'   of the two-sided formula case.)
#'   - As a special `"by"` convenience keyword, in which case facets will match
#'   the grouping variable(s) passed to `by` above.
#' @param facet.args an optional list of arguments for controlling faceting
#'   behaviour. (Ignored if `facet` is NULL.) Supported arguments are as
#'   follows:
#'   - `nrow`, `ncol` for overriding the default "square" facet window
#'   arrangement. Only one of these should be specified, but `nrow` will take
#'   precedence if both are specified together. Ignored if a two-sided formula
#'   is passed to the main `facet` argument, since the layout is arranged in a
#'   fixed grid.
#'   - `free` a logical value indicating whether the axis limits (scales) for
#'   each individual facet should adjust independently to match the range of
#'   the data within that facet. Default is `FALSE`. Separate free scaling of
#'   the x- or y-axis (i.e., whilst holding the other axis fixed) is not
#'   currently supported.
#'   - `fmar` a vector of form `c(b,l,t,r)` for controlling the base margin
#'   between facets in terms of lines. Defaults to the value of `tpar("fmar")`,
#'   which should be `c(1,1,1,1)`, i.e. a single line of padding around each
#'   individual facet, assuming it hasn't been overridden by the user as part
#'   their global \code{\link[tinyplot]{tpar}} settings. Note some automatic
#'   adjustments are made for certain layouts, and depending on whether the plot
#'   is framed or not, to reduce excess whitespace. See
#'   \code{\link[tinyplot]{tpar}} for more details.
#'   - `cex`, `font`, `col`, `bg`, `border` for adjusting the facet title text
#'   and background. Default values for these arguments are inherited from
#'   \code{\link[tinyplot]{tpar}} (where they take a "facet." prefix, e.g.
#'   `tpar("facet.cex")`). The latter function can also be used to set these
#'   features globally for all `tinyplot` plots.
#' @param formula a \code{\link[stats]{formula}} that optionally includes
#'   grouping variable(s) after a vertical bar, e.g. `y ~ x | z`. One-sided
#'   formulae are also permitted, e.g. `~ y | z`. Only a single `y` and `x`
#'   variable (if any) must be specified but multiple grouping variables
#'   can be included in different ways, e.g. `y ~ x | z1:z2` or
#'   `y ~ x | z1 + z2`. (These two representations are treated as equivalent;
#'   both are parsed as `interaction(z1, z2)` internally.) If arithmetic
#'   operators are used for transforming variables, they should be wrapped in
#'   `I()`, e.g., `I(y1/y2) ~ x`. Note that the `formula` and `x` arguments
#'   should not be specified in the same call.
#' @param data a data.frame (or list) from which the variables in formula
#'   should be taken. A matrix is converted to a data frame.
#' @param type character string or call to a `type_*()` function giving the
#'   type of plot desired.
#'   - NULL (default): Choose a sensible type for the type of `x` and `y` inputs
#'     (i.e., usually `"p"`).
#'   - 1-character values supported by \code{\link[graphics]{plot}}:
#'     - `"p"` Points
#'     - `"l"` Lines
#'     - `"b"` Both points and lines
#'     - `"c"` Empty points joined by lines
#'     - `"o"` Overplotted points and lines
#'     - `"s"` Stair steps
#'     - `"S"` Stair steps
#'     - `"h"` Histogram-like vertical lines
#'     - `"n"` Empty plot over the extent of the data
#'   - `tinyplot`-specific types. These fall into several categories:
#'     - Shapes:
#'       - `"area"` / [`type_area()`]: Plots the area under the curve from `y` = 0 to `y` = f(`x`).
#'       - `"errorbar"` / [`type_errorbar()`]: Adds error bars to points; requires `ymin` and `ymax`.
#'       - `"pointrange"` / [`type_pointrange()`]: Combines points with error bars.
#'       - `"polygon"` / [`type_polygon()`]: Draws polygons.
#'       - `"polypath"` / [`type_polypath()`]: Draws a path whose vertices are given in `x` and `y`.
#'       - `"rect"` / [`type_rect()`]: Draws rectangles; requires `xmin`, `xmax`, `ymin`, and `ymax`.
#'       - `"ribbon"` / [`type_ribbon()`]: Creates a filled area between `ymin` and `ymax`.
#'       - `"segments"` / [`type_segments()`]: Draws line segments between pairs of points.
#'       - `"text"` / [`type_text()`]: Add text annotations.
#'     - Visualizations:
#'       - `"barplot"` / [`type_barplot()`]: Creates a bar plot.
#'       - `"boxplot"` / [`type_boxplot()`]: Creates a box-and-whisker plot.
#'       - `"chull"` / [`type_chull()`]: Draws convex hull(s) around grouped points.
#'       - `"density"` / [`type_density()`]: Plots the density estimate of a variable.
#'       - `"histogram"` / [`type_histogram()`]: Creates a histogram of a single variable.
#'       - `"jitter"` / [`type_jitter()`]: Jittered points.
#'       - `"qq"` / [`type_qq()`]: Creates a quantile-quantile plot.
#'       - `"ridge"` / [`type_ridge()`]: Creates a ridgeline (aka joy) plot.
#'       - `"rug"` / [`type_rug()`]: Adds a rug to an existing plot.
#'       - `"spineplot"` / [`type_spineplot()`]: Creates a spineplot or spinogram.
#'       - `"violin"` / [`type_violin()`]: Creates a violin plot.
#'     - Models:
#'       - `"loess"` / [`type_loess()`]: Local regression curve.
#'       - `"lm"` / [`type_lm()`]: Linear regression line.
#'       - `"glm"` / [`type_glm()`]: Generalized linear model fit.
#'       - `"spline"` / [`type_spline()`]: Cubic (or Hermite) spline interpolation.
#'     - Functions:
#'       - [`type_abline()`]: line(s) with intercept and slope.
#'       - [`type_hline()`]: horizontal line(s).
#'       - [`type_vline()`]: vertical line(s).
#'       - [`type_function()`]: arbitrary function.
#'       - [`type_summary()`]: summarize `y` by unique values of `x`.
#' @param legend one of the following options:
#'    - NULL (default), in which case the legend will be determined by the
#'    grouping variable. If there is no group variable (i.e., `by` is NULL) then
#'    no legend is drawn. If a grouping variable is detected, then an automatic
#'    legend is drawn to the _outer_ right of the plotting area. Note that the
#'    legend title and categories will automatically be inferred from the `by`
#'    argument and underlying data.
#'    - A convenience string indicating the legend position. Supported keywords:
#'      - Standard position keywords from base `legend()`, e.g. `"right"`,
#'        `"topleft"`, `"bottom"`, etc.
#'      - Outer positions via a trailing `"!"`, e.g. `"right!"`, `"topleft!"`,
#'        or `"bottom!"`. This places the legend _outside_ the plotting area and
#'        adjusts the margins accordingly.
#'      - `"direct"`: places text labels at the last point of each group's data,
#'        coloured to match. Best suited to line-based plots with x-sorted data,
#'        where "last" corresponds to "rightmost". The right margin is
#'        automatically expanded to prevent clipping. Requires discrete groups
#'        via `by`. For faceted plots, labels are drawn in each panel for the
#'        groups present there. Supports additional arguments when passed
#'        via `legend(...)`:
#'        - `nudge_x`, `nudge_y`: numeric vectors of per-group offsets in data
#'          units. Recycled to the number of groups. Supports named vectors
#'          for targeted adjustment, e.g.
#'          `nudge_y = c("Group A" = -10, "Group B" = 20)`.
#'        - `repel`: if `TRUE`, automatically separates overlapping labels
#'          vertically. If a positive number, sets the minimum gap between
#'          labels in data units.
#'      - `"none"`: turns off legend printing.
#'    - Logical value, where TRUE corresponds to the default case above (same
#'    effect as specifying NULL) and FALSE turns the legend off (same effect as
#'    specifying "none").
#'    - A list or, equivalently, a dedicated `legend()` function with supported
#'    legend arguments, e.g. "bty", "horiz", and so forth. To suppress the
#'    legend title, set `title = NULL` or `title = FALSE`, e.g.
#'    `legend = list(title = FALSE)`.
#' @param main a main title for the plot, see also `title`.
#' @param sub a subtitle for the plot.
#' @param cap a caption for the plot, drawn at the bottom. Useful for
#'   annotations like data sources. Best paired with a dynamic
#'   \code{\link[tinyplot]{tinytheme}}. For the default theme, should be seen as
#'   a substitute for `sub`, since these two will otherwise overlap. Appearance
#'   can be customized via \code{\link[tinyplot]{tpar}} parameters `adj.cap`,
#'   `cex.cap`, `col.cap`, `font.cap`, and `line.cap`.
#' @param xlab a label for the x axis, defaults to a description of x.
#' @param ylab a label for the y axis, defaults to a description of y.
#' @param ann a logical value indicating whether the default annotation (title
#'   and x and y axis labels) should appear on the plot.
#' @param xlim the x limits (x1, x2) of the plot. Note that x1 > x2 is allowed
#'   and leads to a reversed axis (although see the `"rev(erse)"` keyword option
#'   below). The default value, `NULL`, indicates that the range of the `finite`
#'   values to be plotted should be used. Alongside the standard length-2 vector
#'   (e.g., `xlim = c(0, 1)`), `tinyplot` supports three further convenience
#'   forms:
#'   - a single scalar (e.g. `xlim = 0`) ensures that the provided value is
#'     covered by the axis range, irrespective of the data extent.
#'   - a length-2 vector with one `NA` (e.g. `xlim = c(0, NA)`) pins the
#'     non-`NA` limit and lets the data determine the other limit.
#'   - the convenience string `"rev"` (or the longer `"reverse"`) reverses the
#'     automatically-computed axis range. This is equivalent to passing a
#'     descending vector, but without having to know the data extent in advance.
#' @param ylim the y limits of the plot. Accepts the same input forms as `xlim`.
#' @param axes logical or character. Should axes be drawn (`TRUE` or `FALSE`)?
#'   Or alternatively what type of axes should be drawn: `"standard"` (with
#'   axis, ticks, and labels; equivalent to `TRUE`), `"none"` (no axes;
#'   equivalent to `FALSE`), `"ticks"` (only ticks and labels without axis line),
#'   `"labels"` (only labels without ticks and axis line), `"axis"` (only axis
#'   line and labels but no ticks). To control this separately for the two
#'   axes, use the character specifications for `xaxt` and/or `yaxt`.
#' @param xaxt,yaxt character specifying the type of x-axis and y-axis,
#'   respectively. See `axes` for the possible values.
#' @param xaxs,yaxs character specifying the style of the interval calculation
#'   used for the x-axis and y-axis, respectively. See
#'   \code{\link[graphics]{par}} for the possible values.
#' @param xaxb,yaxb numeric vector (or character vector, if appropriate) giving
#'   the break points at which the axis tick-marks are to be drawn. Break points
#'   outside the range of the data will be ignored if the associated axis
#'   variable is categorical, or an explicit `x/ylim` range is given.
#' @param xaxl,yaxl a function or a character keyword specifying the format of
#'   the x- or y-axis tick labels. Note that this is a post-processing step that
#'   affects the _appearance_ of the tick labels only; use in conjunction with
#'   `x/yaxb` if you would like to adjust the position of the tick marks too. In
#'   addition to user-supplied formatting functions (e.g., [`format`],
#'   [`toupper`], [`abs`], or other custom function), several convenience
#'   keywords (or their symbol equivalents) are available for common formatting
#'   transformations: `"percent"` (`"%"`), `"comma"` (`","`), `"log"` (`"l"`),
#'   `"dollar"` (`"$"`), `"euro"` (`"€"`), or `"sterling"` (`"£"`). See the
#'   [`tinylabel`] documentation for examples.
#' @param log a character string which contains `"x"` if the x axis is to be
#'   logarithmic, `"y"` if the y axis is to be logarithmic and `"xy"` or `"yx"`
#'   if both axes are to be logarithmic.
#' @param flip logical. Should the plot orientation be flipped, so that the
#'   y-axis is on the horizontal plane and the x-axis is on the vertical plane?
#'   Default is FALSE.
#' @param frame.plot a logical indicating whether a box should be drawn around
#'   the plot. Can also use `frame` as an acceptable argument alias.
#'   The default is to draw a frame if both axis types (set via `axes`, `xaxt`,
#'   or `yaxt`) include axis lines.
#' @param grid argument for plotting a background panel grid, one of:
#'    - a logical (i.e., `TRUE` to draw the grid on both axes).
#'    - a character string controlling which axes get grid lines and at what
#'      resolution. Uppercase letters (`"X"`, `"Y"`, `"XY"`) draw grid lines at
#'      the standard axis tick positions (with the latter equivalent to `TRUE`),
#'      while lowercase letters (`"x"`, `"y"`, `"xy"`) draw a finer grid with
#'      additional lines at the midpoints between ticks. (Note: the finer grid
#'      has no effect on log-scale axes, since tick positions already include
#'      intermediate values.)
#'    - a panel grid plotting function like `grid()`.
#'   Note that this argument replaces the `panel.first` and `panel.last`
#'   arguments from base `plot()` and tries to make the process more seamless
#'   with better default behaviour. The default behaviour is determined by (and
#'   can be set globally through) the value of `tpar("grid")`.
#' @param palette one of the following options:
#'    - NULL (default), in which case the palette will be chosen according to
#'    the class and cardinality of the "by" grouping variable. For non-ordered
#'    factors or strings with a reasonable number of groups, this will inherit
#'    directly from the user's default \code{\link[grDevices]{palette}} (e.g.,
#'    "R4"). In other cases, including ordered factors and high cardinality, the
#'    "Viridis" palette will be used instead. Note that a slightly restricted
#'    version of the "Viridis" palette---where extreme color values have been
#'    trimmed to improve visual perception---will be used for ordered factors
#'    and continuous variables. In the latter case of a continuous grouping
#'    variable, we also generate a gradient legend swatch.
#'    - A convenience string corresponding to one of the many palettes listed by
#'    either `palette.pals()` or `hcl.pals()`. Note that the string can be
#'    case-insensitive (e.g., "Okabe-Ito" and "okabe-ito" are both valid).
#'    - A palette-generating function. This can be "bare" (e.g.,
#'    `palette.colors`) or "closed" with a set of named arguments (e.g.,
#'    `palette.colors(palette = "Okabe-Ito", alpha = 0.5)`). Note that any
#'    unnamed arguments will be ignored and the key `n` argument, denoting the
#'    number of colours, will automatically be spliced in as the number of
#'    groups.
#'    - A vector or list of colours, e.g. `c("darkorange", "purple", "cyan4")`.
#'    If too few colours are provided for a discrete (qualitative) set of
#'    groups, then the colours will be recycled with a warning. For continuous
#'    (sequential) groups, a gradient palette will be interpolated. 
#' @param col plotting color. Character, integer, or vector of length equal to
#'   the number of categories in the `by` variable. See `col`. Note that the
#'   default behaviour in `tinyplot` is to vary group colors along any variables
#'   declared in the `by` argument. Thus, specifying colors manually should not
#'   be necessary unless users wish to override the automatic colors produced by
#'   this grouping process. Typically, this would only be done if grouping
#'   features are deferred to some other graphical parameter (i.e., passing the
#'   "by" keyword to one of `pch`, `lty`, `lwd`, or `bg`; see below.)
#' @param pch plotting "character", i.e., symbol to use. Character, integer, or
#'   vector of length equal to the number of categories in the `by` variable.
#'   See `pch`. In addition, users can supply a special `pch = "by"` convenience
#'   argument, in which case the characters will automatically loop over the
#'   number groups. This automatic looping will begin at the global character
#'   value (i.e., `par("pch")`) and recycle as necessary.
#' @param lty line type. Character, integer, or vector of length equal to the
#'   number of categories in the `by` variable. See `lty`. In addition, users
#'   can supply a special `lty = "by"` convenience argument, in which case the
#'   line type will automatically loop over the number groups. This automatic
#'   looping will begin at the global line type value (i.e., `par("lty")`) and
#'   recycle as necessary.
#' @param lwd line width. Numeric scalar or vector of length equal to the
#'   number of categories in the `by` variable. See `lwd`. In addition, users
#'   can supply a special `lwd = "by"` convenience argument, in which case the
#'   line width will automatically loop over the number of groups. This
#'   automatic looping will be centered at the global line width value (i.e.,
#'   `par("lwd")`) and pad on either side of that.
#' @param bg background fill color for the open plot symbols 21:25 (see
#'   `points.default`), as well as ribbon and area plot types.
#'   Users can also supply either one of two special convenience arguments that
#'   will cause the background fill to inherit the automatic grouped coloring
#'   behaviour of `col`:
#'
#'   - `bg = "by"` will insert a background fill that inherits the main color
#'   mappings from `col`.
#'   - `by = <numeric[0,1]>` (i.e., a numeric in the range `[0,1]`) will insert
#'   a background fill that inherits the main color mapping(s) from `col`, but
#'   with added alpha-transparency.
#'
#'   For both of these convenience arguments, note that the (grouped) `bg`
#'   mappings will persist even if the (grouped) `col` defaults are themselves
#'   overridden. This can be useful if you want to preserve the grouped palette
#'   mappings by background fill but not boundary color, e.g. filled points. See
#'   examples.
#' @param fill alias for `bg`. If non-NULL values for both `bg` and `fill` are
#'   provided, then the latter will be ignored in favour of the former.
#' @param alpha a numeric in the range `[0,1]` for adjusting the alpha channel
#'   of the color palette, where 0 means transparent and 1 means opaque. Use
#'   fractional values, e.g. `0.5` for semi-transparency.
#' @param cex character expansion. A numerical vector (can be a single value)
#'   giving the amount by which plotting characters and symbols should be scaled
#'   relative to the default. Note that `NULL` is equivalent to 1.0, while `NA`
#'   renders the characters invisible. There are two additional considerations,
#'   specifically for points-alike plot types (e.g. `"p"`):
#'   
#'   - users can also supply a special `cex = "by"` convenience argument, in
#'     which case the character expansion will automatically adjust by group
#'     too. The range of this character expansion is controlled by the `clim`
#'     argument in the respective types; see [`type_points()`] for example.
#'   - passing a `cex` vector of equal length to the main `x` and `y` variables
#'     (e.g., another column in the same dataset) will yield a "bubble"plot with
#'     its own dedicated legend. This can provide a useful way to visualize an
#'     extra dimension of the data; see Examples.
#' @param weights an optional numeric vector of observation weights, of the same
#'   length as the number of `x`,`y` coordinates. Can be passed as an unquoted
#'   (NSE) variable name when called from the `tinyplot.formula` method, e.g.
#'   `weights = varname` where `varname` is a variable in `data`. Weights are
#'   only consumed by the plot types that support them; specifically, the model
#'   fit and distribution types like [`type_lm()`] and [`type_spineplot()`].
#'   Because weights trigger a statistical transformation, a warning is emitted
#'   if this argument is supplied to a non-supported type (even though the
#'   actual weights will be ignored, regardless).
#' @param labels an optional vector of text labels for use with [`type_text()`],
#'   of length 1 or the same length as the number of `x`,`y` coordinates. Can be
#'   passed as an unquoted (NSE) variable name when called from the
#'   `tinyplot.formula` method, e.g. `labels = varname` where `varname` is a
#'   variable in `data`. Ignored by other plot types. Overrides the `labels`
#'   argument of [`type_text()`] if both are supplied.
#' @param subset,na.action,drop.unused.levels arguments passed to `model.frame`
#'   when extracting the data from `formula` and `data`.
#' @param add logical. If TRUE, then elements are added to the current plot rather
#'   than drawing a new plot window. Note that the automatic legend for the
#'   added elements will be turned off. See also [tinyplot_add], which provides
#'   a convenient wrapper around this functionality for layering on top of an
#'   existing plot without having to repeat arguments.
#' @param draw a function that draws directly on the plot canvas (before `x` and
#'   `y` are plotted). The `draw` argument is primarily useful for adding common
#'   elements to each facet of a faceted plot, e.g.
#'   \code{\link[graphics]{abline}} or \code{\link[graphics]{text}}. Note that
#'   this argument is somewhat experimental and that _no_ internal checking is
#'   done for correctness; the provided argument is simply captured and
#'   evaluated as-is within `tinyplot()` and thus has access to the local
#'   definition of all variables such as `x`, `y`, etc. See Examples.
#' @param restore.par a logical value indicating whether the
#'   \code{\link[graphics]{par}} settings prior to calling `tinyplot` should be
#'   restored on exit. Defaults to FALSE, which makes it possible to add
#'   elements to the plot after it has been drawn. However, note the the outer
#'   margins of the graphics device may have been altered to make space for the
#'   `tinyplot` legend. Users can opt out of this persistent behaviour by
#'   setting to TRUE instead. See also [get_saved_par] for another option to
#'   recover the original \code{\link[graphics]{par}} settings, as well as
#'   longer discussion about the trade-offs involved.
#' @param empty logical indicating whether the interior plot region should be
#'  left empty. The default is `FALSE`. Setting to `TRUE` has a similar effect
#'  to invoking `type = "n"` above, except that any legend artifacts owing to a
#'  particular plot type (e.g., lines for `type = "l"` or squares for
#'  `type = "area"`) will still be drawn correctly alongside the empty plot. In
#'  contrast,`type = "n"` implicitly assumes a scatterplot and so any legend
#'  will only depict points.
#' @param file character string giving the file path for writing a plot to disk.
#'   If specified, the plot will not be displayed interactively, but rather sent
#'   to the appropriate external graphics device (i.e.,
#'   \code{\link[grDevices]{png}}, \code{\link[grDevices]{jpeg}},
#'   \code{\link[grDevices]{pdf}}, or \code{\link[grDevices]{svg}}). As a point
#'   of convenience, note that any global parameters held in `(t)par` are
#'   automatically carried over to the external device and don't need to be
#'   reset (in contrast to the conventional base R approach that requires
#'   manually opening and closing the device). The device type is determined by
#'   the file extension at the end of the provided path, and must be one of
#'   ".png", ".jpg" (".jpeg"), ".pdf", or ".svg". (Other file types may be
#'   supported in the future.) The file dimensions can be controlled by the
#'   corresponding `width` and `height` arguments below, otherwise will fall
#'   back to the `"file.width"` and `"file.height"` values held in
#'   \code{\link[tinyplot]{tpar}} (i.e., both defaulting to 7 inches, and where
#'   the default resolution for bitmap files is also specified as 300
#'   DPI).
#' @param width numeric giving the plot width in inches. Together with `height`,
#'  typically used in conjunction with the `file` argument above, overriding the
#'  default values held in `tpar("file.width", "file.height")`. If either `width`
#'  or `height` is specified, but a corresponding `file` argument is not
#'  provided as well, then a new interactive graphics device dimensions will be
#'  opened along the given dimensions. Note that this interactive resizing may
#'  not work consistently from within an IDE like RStudio that has an integrated
#'  graphics windows.
#' @param height numeric giving the plot height in inches. Same considerations as
#'  `width` (above) apply, e.g. will default to `tpar("file.height")` if not
#'  specified.
#' @param asp the y/xy/x aspect ratio, see `plot.window`.
#' @param theme keyword string (e.g. `"clean"`) or list defining a theme. Passed
#'  on to [`tinytheme`], but reset upon exit so that the theme effect is only
#'  temporary. Useful for invoking ephemeral themes. 
#' @param ... other graphical parameters. If `type` is a character specification
#'   (such as `"hist"`) then any argument names that match those from the corresponding
#'   `type_*()` function (such as \code{\link{type_hist}}) are passed on to that.
#'   All remaining arguments from `...` can be further graphical parameters, see
#'   \code{\link[graphics]{par}}).
#'
#' @returns No return value, called for side effect of producing a plot.
#'
#' @details
#' Disregarding the enhancements that it supports, `tinyplot` tries as far as
#' possible to mimic the behaviour and syntax logic of the original base
#' \code{\link[graphics]{plot}} function. Users should therefore be able to swap
#' out existing `plot` calls for `tinyplot` (or its shorthand alias `plt`),
#' without causing unexpected changes to the output.
#'
#' @importFrom grDevices axisTicks adjustcolor cairo_pdf chull colorRampPalette dev.cur dev.list dev.off dev.new extendrange hcl.colors hcl.pals jpeg palette palette.colors palette.pals pdf png svg xy.coords
#' @importFrom graphics abline arrows axis Axis axTicks box boxplot grconvertX grconvertY hist lines mtext par plot.default plot.new plot.window points polygon polypath segments rect text title
#' @importFrom utils modifyList head tail
#' @importFrom stats na.omit setNames
#' @importFrom tools file_ext
#'
#' @examples
#' aq = transform(
#'   airquality,
#'   Month = factor(Month, labels = month.abb[unique(Month)])
#' )
#'
#' # In most cases, `tinyplot` should be a drop-in replacement for regular
#' # `plot` calls. For example:
#'
#' op = tpar(mfrow = c(1, 2))
#' plot(0:10, main = "plot")
#' tinyplot(0:10, main = "tinyplot")
#' tpar(op) # restore original layout
#'
#' # Aside: `tinyplot::tpar()` is a (near) drop-in replacement for `par()`
#'
#' # Unlike vanilla plot, however, tinyplot allows you to characterize groups
#' # using either the `by` argument or equivalent `|` formula syntax.
#'
#' with(aq, tinyplot(Day, Temp, by = Month)) ## atomic method
#' tinyplot(Temp ~ Day | Month, data = aq) ## formula method
#'
#' # (Notice that we also get an automatic legend.)
#'
#' # You can also use the equivalent shorthand `plt()` alias if you'd like to
#' # save on a few keystrokes
#'
#' plt(Temp ~ Day | Month, data = aq) ## shorthand alias
#'
#' # Use standard base plotting arguments to adjust features of your plot.
#' # For example, change `pch` (plot character) to get filled points and `cex`
#' # (character expansion) to increase their size.
#'
#' tinyplot(
#'   Temp ~ Day | Month,
#'   data = aq,
#'   pch = 16,
#'   cex = 2
#' )
#' 
#' # Use the special "by" convenience keyword if you would like to map these
#' # aesthetic features over groups too (i.e., in addition to the default
#' # colour grouping)
#' 
#' tinyplot(
#'   Temp ~ Day | Month,
#'   data = aq,
#'   pch = "by",
#'   cex = "by"
#' )
#'
#' # We can add alpha transparency for overlapping points
#'
#' tinyplot(
#'   Temp ~ Day | Month,
#'   data = aq,
#'   pch = 16,
#'   cex = 2,
#'   alpha = 0.3
#' )
#'
#' # To get filled points with a common solid background color, use an
#' # appropriate plotting character (21:25) and combine with one of the special
#' # `bg`/`fill` convenience arguments.
#' tinyplot(
#'   Temp ~ Day | Month,
#'   data = aq,
#'   pch = 21, # use filled circles
#'   cex = 2,
#'   bg = 0.3, # numeric in [0,1] adds a grouped background fill with transparency
#'   col = "black" # override default color mapping; give all points a black border
#' )
#' 
#' # Aside: For "bubble" plots, pass an appropriate vector to the `cex` arg.
#' # This can be useful for depicting an additional dimension of the data (here:
#' # Wind).
#' tinyplot(
#'   Temp ~ Day | Month,
#'   data = aq,
#'   pch = 21,
#'   cex = aq$Wind, # map character size to another feature in the data
#'   bg = 0.3,
#'   col = "black"
#' )
#'
#' # Converting to a grouped line plot is a simple matter of adjusting the
#' # `type` argument.
#'
#' tinyplot(
#'   Temp ~ Day | Month,
#'   data = aq,
#'   type = "l"
#' )
#'
#' # Similarly for other plot types, including some additional ones provided
#' # directly by tinyplot, e.g. density plots or internal plots (ribbons,
#' # pointranges, etc.)
#'
#' tinyplot(
#'   ~ Temp | Month,
#'   data = aq,
#'   type = "density",
#'   fill = "by"
#' )
#'
#' # Facet plots are supported too. Facets can be drawn on their own...
#'
#' tinyplot(
#'   Temp ~ Day,
#'   facet = ~Month,
#'   data = aq,
#'   type = "area",
#'   main = "Temperatures by month"
#' )
#'
#' # ... or combined/contrasted with the by (colour) grouping.
#'
#' aq = transform(aq, Summer = Month %in% c("Jun", "Jul", "Aug"))
#' tinyplot(
#'   Temp ~ Day | Summer,
#'   facet = ~Month,
#'   data = aq,
#'   type = "area",
#'   palette = "dark2",
#'   main = "Temperatures by month and season"
#' )
#'
#' # Users can override the default square window arrangement by passing `nrow`
#' # or `ncol` to the helper facet.args argument. Note that we can also reduce
#' # axis label repetition across facets by turning the plot frame off.
#'
#' tinyplot(
#'   Temp ~ Day | Summer,
#'   facet = ~Month, facet.args = list(nrow = 1),
#'   data = aq,
#'   type = "area",
#'   palette = "dark2",
#'   frame = FALSE,
#'   main = "Temperatures by month and season"
#' )
#'
#' # Use a two-sided formula to arrange the facet windows in a fixed grid.
#' # LHS -> facet rows; RHS -> facet columns
#'
#' aq$hot = ifelse(aq$Temp >= 75, "hot", "cold")
#' aq$windy = ifelse(aq$Wind >= 15, "windy", "calm")
#' tinyplot(
#'   Temp ~ Day,
#'   facet = windy ~ hot,
#'   data = aq
#' )
#'
#' # To add common elements to each facet, use the `draw` argument
#'
#' tinyplot(
#'   Temp ~ Day,
#'   facet = windy ~ hot,
#'   data = aq,
#'   draw = abline(h = 75, lty = 2, col = "hotpink")
#' )
#'
#' # The (automatic) legend position and look can be customized using
#' # appropriate arguments. Note the trailing "!" in the `legend` position
#' # argument below. This tells `tinyplot` to place the legend _outside_ the plot
#' # area.
#'
#' tinyplot(
#'   Temp ~ Day | Month,
#'   data = aq,
#'   type = "l",
#'   legend = legend("bottom!", title = "Month of the year", bty = "o")
#' )
#'
#' # Use legend = "direct" to place text labels at the last point of each
#' # group's data, coloured to match. Best suited to line-based plots with
#' # x-sorted data, where "last" corresponds to "rightmost". The right
#' # margin is automatically expanded to fit the labels. Pairs well with
#' # dynamic themes for tighter margins overall.
#'
#' tinyplot(
#'   Temp ~ Day | Month,
#'   data = aq,
#'   type = "l",
#'   legend = "direct",
#'   theme = "clean2"
#' )
#'
#' # Use `nudge_x/y`` to manually adjust label positions, or `repel` to
#' # auto-separate overlapping labels:
#' tinyplot(
#'   Temp ~ Day | Month,
#'   data = aq,
#'   type = "l",
#'   legend = legend("direct", nudge_y = c(May = -1, Jun = 2)),
#'   # legend = legend("direct", repel = TRUE), # another option
#'   theme = "clean2"
#' )
#'
#' # The default group colours are inherited from either the "R4" or "Viridis"
#' # palettes, depending on the number of groups. However, all palettes listed
#' # by `palette.pals()` and `hcl.pals()` are supported as convenience strings,
#' # or users can supply a valid palette-generating function for finer control
#'
#' tinyplot(
#'   Temp ~ Day | Month,
#'   data = aq,
#'   type = "l",
#'   palette = "tableau"
#' )
#'
#' # It's possible to customize the look of your plots by setting graphical
#' # parameters (e.g., via `(t)par`)... But a more convenient way is to just use
#' # built-in themes (see `?tinytheme`).
#'
#' tinyplot(
#'   Temp ~ Day | Month,
#'   data = aq,
#'   type = "b",
#'   alpha = 0.5,
#'   main = "Daily temperatures by month",
#'   sub = "Brought to you by tinyplot",
#'   cap = "Source: Base R airquality dataset",
#'   theme = "clean2"
#' )
#'
#' # For more examples and a detailed walkthrough, please see the introductory
#' # tinyplot tutorial available online:
#' # https://grantmcdermott.com/tinyplot/vignettes/introduction.html
#'
#' @rdname tinyplot
#' @export
tinyplot =
  function(x, ...) {
    UseMethod("tinyplot")
  }

#' @rdname tinyplot
#' @export
tinyplot.default = function(
    x = NULL,
    y = NULL,
    xmin = NULL,
    xmax = NULL,
    ymin = NULL,
    ymax = NULL,
    weights = NULL,
    labels = NULL,
    by = NULL,
    facet = NULL,
    facet.args = NULL,
    data = NULL,
    type = NULL,
    legend = NULL,
    main = NULL,
    sub = NULL,
    cap = NULL,
    xlab = NULL,
    ylab = NULL,
    ann = par("ann"),
    xlim = NULL,
    ylim = NULL,
    axes = TRUE,
    xaxt = NULL,
    yaxt = NULL,
    xaxs = NULL,
    yaxs = NULL,
    xaxb = NULL,
    yaxb = NULL,
    xaxl = NULL,
    yaxl = NULL,
    log = "",
    flip = FALSE,
    frame.plot = NULL,
    grid = NULL,
    palette = NULL,
    pch = NULL,
    lty = NULL,
    lwd = NULL,
    col = NULL,
    bg = NULL,
    fill = NULL,
    alpha = NULL,
    cex = NULL,
    add = FALSE,
    draw = NULL,
    empty = FALSE,
    restore.par = FALSE,
    file = NULL,
    width = NULL,
    height = NULL,
    asp = NA,
    theme = NULL,
    ...) {

  # Force evaluation of legend if it's a symbol to avoid downstream promise
  # issues. Let sanitize_legend handle it
  if (!missing(legend) && is.symbol(substitute(legend))) {
    legend = legend
  }

  #
  ## save parameters and calls -----
  #
  par_first = get_saved_par("first")
  if (is.null(par_first)) set_saved_par("first", par())
  
  # Validate grid only for simple values; skip for unevaluated calls like grid()
  # which are passed as language objects from tinyplot.formula via substitute(). (#193)
  if (!is.null(grid) && !is.call(grid)) {
    assert_grid(grid, null.ok = TRUE, name = "grid")
  }

  # save for tinyplot_add()
  assert_logical(add)
  if (!add) {
    calls = sys.calls()
    is_tinyplot_call = function(x) identical(tinyplot, try(eval(x[[1L]]), silent = TRUE))
    idx = which(vapply(calls, is_tinyplot_call, FALSE))
    if (length(idx) > 0) {
      set_environment_variable(.last_call = calls[[idx[1L]]])
    }
  }

  # Save current graphical parameters
  opar = par(no.readonly = TRUE)
  if (restore.par || !is.null(facet)) {
    if (!is.null(file) || !is.null(width) || !is.null(height)) {
      opar$new = FALSE # catch for some interfaces
    }
    on.exit(par(opar), add = TRUE)
  }
  set_saved_par(when = "before", opar)

  # Catch for adding to existing facet plot
  if (!is.null(facet) && add) {
    recordGraphics(
      par(get_saved_par(when = "after")),
      list = list(),
      env = getNamespace('tinyplot')
    )
  }

  # Ephemeral theme
  if (!is.null(theme)) {
    if (is.character(theme) && length(theme) == 1) {
      tinytheme(theme)
    } else if (is.list(theme)) {
      do.call(tinytheme, theme)
    } else {
      warning('Argument `theme` must be a character of length 1 (e.g. "clean"), or a list. Ignoring.')
    }
    if (is.character(theme) && theme == "default") {
      # Reset mar to pre-theme value so legend margin adjustment isn't
      # clobbered. Only needed for "default" theme which uses hook = FALSE
      # and thus sets par(mar) immediately. (#557)
      par(mar = opar$mar)
      on.exit(init_tpar(rm_hook = TRUE), add = TRUE)
    } else {
      dtheme = theme_default
      otheme = opar[names(dtheme)]
      on.exit(do.call(tinytheme, otheme), add = TRUE)
    }
  }

  #
  ## settings container -----
  #

  dots = list(...)

  # resolve any axis-reversal keyword (e.g. xlim = "reverse") into a flag, and
  # reset the limit to NULL so the normal auto-range path runs (see lim.R)
  .revx = sanitize_lim_rev(xlim, "xlim")
  xlim = .revx[["lim"]]; rev_x = .revx[["rev"]]
  .revy = sanitize_lim_rev(ylim, "ylim")
  ylim = .revy[["lim"]]; rev_y = .revy[["rev"]]

  settings_list = list(
    # save call to check user input later
    call          = match.call(),

    # save to file & device dimensions
    file          = file,
    width         = width,
    height        = height,

    # deparsed input for use in labels
    by_dep        = deparse1(substitute(by)),
    cex_dep       = if (!is.null(cex)) deparse1(substitute(cex)) else NULL,
    facet_dep     = deparse1(substitute(facet)),
    x_dep         = if (is.null(x))    NULL else deparse1(substitute(x)),
    xmax_dep      = if (is.null(xmax)) NULL else deparse1(substitute(xmax)),
    xmin_dep      = if (is.null(xmin)) NULL else deparse1(substitute(xmin)),
    y_dep         = if (is.null(y))    NULL else deparse1(substitute(y)),
    ymax_dep      = if (is.null(ymax)) NULL else deparse1(substitute(ymax)),
    ymin_dep      = if (is.null(ymin)) NULL else deparse1(substitute(ymin)),

    # types
    type          = type,
    type_data     = NULL,
    type_draw     = NULL,
    type_name     = NULL,

    # type-specific settings
    bubble        = FALSE,
    bubble_pch    = NULL,
    bubble_alpha  = NULL,
    bubble_bg_alpha = NULL,
    ygroup        = NULL,  # for type_ridge()

    # data points and labels
    x             = x,
    xmax          = xmax,
    xmin          = xmin,
    xlab          = xlab,
    xlabs         = NULL,
    y             = y,
    ymax          = ymax,
    ymin          = ymin,
    ylab          = ylab,
    ylabs         = NULL,
    weights       = weights,
    labels        = labels,
    weights_used  = FALSE, # set TRUE by type_data() functions that consume weights

    # axes
    axes          = axes,
    xaxt          = xaxt,
    xaxb          = xaxb,
    xaxl          = xaxl,
    xaxs          = xaxs,
    yaxt          = yaxt,
    yaxb          = yaxb,
    yaxl          = yaxl,
    yaxs          = yaxs,
    frame.plot    = frame.plot,
    xlim          = xlim,
    ylim          = ylim,
    rev_x         = rev_x,
    rev_y         = rev_y,

    # flags to check user input (useful later on)
    null_by       = is.null(by),
    null_xlim     = is.null(xlim),
    null_ylim     = is.null(ylim),
    # when palette functions need pre-processing this check raises error
    null_palette  = tryCatch(is.null(palette), error = function(e) FALSE),
    x_by          = identical(x, by), # for "boxplot", "spineplot" and "ridge"

    # unevaluated expressions with side effects
    draw          = substitute(draw),
    facet         = facet,
    facet.args    = facet.args,
    palette       = substitute(palette),
    legend        = if (add) FALSE else substitute(legend),

    # aesthetics
    lty           = lty,
    lwd           = lwd,
    col           = col,
    bg            = bg,
    log           = log,
    fill          = fill,
    alpha         = alpha,
    cex           = cex,
    pch           = if (is.null(pch)) get_tpar("pch", default = NULL) else pch,

    # ribbon.alpha overwritten by some type_data() functions
    # sanitize_ribbon_alpha: returns default alpha transparency for ribbon-type plots
    ribbon.alpha  = sanitize_ribbon_alpha(NULL),

    # misc
    add           = add,
    by            = by,
    cap           = cap,
    dodge         = NULL,
    dots          = dots,
    flip          = flip,
    lighten       = TRUE, # area types may override via their type_data() fn
    group_offsets = NULL,
    offsets_axis  = NULL,
    sub           = sub,
    type_info     = list() # pass type-specific info from type_data to type_draw
  )

  settings = new.env()
  list2env(settings_list, settings)

  #
  ## devices and files -----
  #
  # Write plot to output file or window with fixed dimensions
  setup_device(settings)
  if (!is.null(settings$file)) on.exit(dev.off(), add = TRUE)


  #
  ## sanitize arguments -----
  #

  # extract legend_args from dots
  if ("legend_args" %in% names(dots)) {
    settings$legend_args = settings$dots[["legend_args"]]
    settings$dots$legend_args = NULL # avoid passing both directly and via ...
  } 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

  # validate types and returns a list with name, data, and draw components
  sanitize_type(settings)
  
  # standardize axis arguments and returns consistent axes, xaxt, yaxt, frame.plot
  sanitize_axes(settings)

  # generate appropriate axis labels based on input data and plot type
  sanitize_xylab(settings)

  # palette default
  if (is.null(settings$palette)) {
    settings$palette = get_tpar("palette", default = NULL)
  }

  # by: coerce character groups to factor
  if (!settings$null_by && is.character(settings$by)) {
    settings$by = factor(settings$by)
  }

  # flag if x==by, currently only used for 
  # 


  # facet: parse facet formula and prepares variables when facet==by
  sanitize_facet(settings)

  # x / y processing
  # convert characters to factors
  # set x automatically when omitted (ex: rect)
  # set y automatically when omitted (ex: boxplots)
  # combine x, y, xmax, by, facet etc. into a single `datapoints` data.frame
  sanitize_datapoints(settings)


  #
  ## transform datapoints using type_data() -----
  #

  if (!is.null(settings$type_data)) {
    settings$type_data(settings, ...)
  }

  # Warn if weights were supplied but the plot type ignored them. This is a
  # deliberate exception to how other unconsumed top-level args (e.g.
  # xmin/xmax/ymin/ymax) are silently dropped: a missing ribbon is visually
  # obvious, but ignored weights produce a normal-looking yet statistically
  # wrong plot. Consuming types opt in by setting `weights_used`, so new
  # weight-aware types are covered automatically (#332).
  if (!is.null(settings$weights) && !isTRUE(settings$weights_used)) {
    warning(
      "`weights` were supplied but are ignored by this plot type.",
      call. = FALSE
    )
  }

  # ensure axis aligment of any added layers
  if (!add) {
    assign("xlabs_orig", settings[["xlabs"]], envir = get(".tinyplot_env", envir = parent.env(environment())))
    assign(".group_offsets", settings[["group_offsets"]], envir = get(".tinyplot_env", envir = parent.env(environment())))
    assign(".offsets_axis", settings[["offsets_axis"]], envir = get(".tinyplot_env", envir = parent.env(environment())))
  } else {
    align_layer(settings)
  }

  # flip -> swap x and y after type_data, except for boxplots (which has its own bespoke flip logic)
  flip_datapoints(settings)


  #
  ## bubble plot -----
  #
  
  # Transform cex values for bubble charts. Handles size transformation, legend
  # gotchas, and aesthetic sanitization.
  # Currently limited to "p" and "text" types, but could expand to others.
  bubble(settings)


  #
  ## axis breaks and limits -----
  #
  
  # do this after computing yaxb because limits will depend on the previous calculations
  if (!add) {
    lim_args(settings)
  }

  #
  ## facets: count -----
  #

  # facet_layout processes facet simplification, attribute restoration, and layout
  facet_layout(settings)


  #
  ## aesthetics by group -----
  #
  
  by_aesthetics(settings)


  #
  ## legends -----
  #
  
  prepare_legend(settings)

  #
  ## make settings available in the environment directly -----
  #

  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 = get_theme_def(.tinytheme)
    if (identical(.theme_def, theme_default)) .theme_def = NULL
    .theme_mar = if (!is.null(.theme_def[["mar"]])) .theme_def[["mar"]] else par("mar")
    .tpars = if (!is.null(.theme_def)) modifyList(.theme_def, tpar()) 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)
    }
    if (!is.null(.tpars[["mar"]])) .theme_mar = .tpars[["mar"]]

    .cex_axis = get_tpar("cex.axis", tpar_list = .tpars, default = 1)
    .cex_lab = get_tpar(c("cex.ylab", "cex.lab"), tpar_list = .tpars, default = 1)
    .las = get_tpar("las", tpar_list = .tpars, default = par("las"))
    .ymgp_shift = if (.las %in% c(0L, 1L)) 0.5 * (.cex_axis - 1) else 0
    .ylab_cex_shift = 0.5 * (.cex_lab - 1)

    # 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)
    )

    # Types that suppress the standard axes (xaxt/yaxt = "n") only because they
    # draw their own in the same place (e.g. spineplot category + numeric labels
    # via spine_axis(); ridge y-axis category labels via tinyAxis()). Their
    # tick-row margin must still be reserved, else the self-drawn labels clip --
    # or, under las 0/1 where mar is further shifted, error -- when xlab/ylab = NA
    # makes label_extent = 0 (#635, #650).
    .self_axis = type %in% c("spineplot", "ridge")

    .dyn = c(
      dynmar_side(1, xlab, main = main, sub = sub,
                  cap = if (.outer_sides[1]) NULL else cap,
                  side.sub = .side.sub,
                  axis_on = .self_axis ||
                    (!identical(xaxt, "none") && !identical(xaxt, "n")),
                  tpars = .tpars),
      dynmar_side(2, ylab,
                  axis_on = .self_axis ||
                    (!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)
    )
    if (.ymgp_shift > 0) .dyn[2] = .dyn[2] - .ymgp_shift
    # 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.
    # Exception: "top!" lives in mar[3] (not oma), so keep the baseline padding.
    .outer_sides_oma = .outer_sides & c(TRUE, TRUE, FALSE, TRUE)
    .theme_mar[.outer_sides_oma] = 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. Pass .cex_axis to strwidth so measurements
    # reflect the intended text size (par("cex.axis") isn't set yet either).
    .whtsbp = c(0, 0, 0, 0)
    .whtsbp_y_raw = 0
    .whtsbp_x_raw = 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 {
        ylim_usr = if (diff(ylim) == 0 && is.null(yaxb)) ylim + c(-0.5, 0.5) else extendrange(ylim, f = 0.04)
        yaxlabs = axisTicks(usr = ylim_usr, log = par("ylog"))
      }
      if (!is.null(yaxl)) yaxlabs = tinylabel(yaxlabs, yaxl)
      .whtsbp_y_raw = grconvertX(max(strwidth(yaxlabs, "figure", cex = .cex_axis)), from = "nfc", to = "lines") -
                      grconvertX(0, from = "nfc", to = "lines") - 0.5
      if (is.finite(.whtsbp_y_raw)) .whtsbp[2] = .whtsbp_y_raw
    }
    if (.las %in% 2:3) {
      xlim_usr = if (diff(xlim) == 0 && is.null(xaxb)) xlim + c(-0.5, 0.5) else extendrange(xlim, f = 0.04)
      xaxlabs = if (is.null(xlabs)) axisTicks(usr = xlim_usr, log = par("xlog")) else
        if (!is.null(names(xlabs))) names(xlabs) else xlabs
      if (!is.null(xaxl)) xaxlabs = tinylabel(xaxlabs, xaxl)
      .whtsbp_x_raw = grconvertX(max(strwidth(xaxlabs, "figure", cex = .cex_axis)), from = "nfc", to = "lines") - 0.5
      if (is.finite(.whtsbp_x_raw)) .whtsbp[1] = .whtsbp_x_raw
    }

    # 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
      .whtsbp_y_raw = .whtsbp_y_raw * cex_fct_adj
      .whtsbp_x_raw = .whtsbp_x_raw * cex_fct_adj
    }

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

  if (legend_draw_flag && !identical(legend_args[["x"]], "direct")) {
    if (!multi_legend) {
      ## simple case: single legend only
      if (is.null(lgnd_cex)) lgnd_cex = cex * cex_fct_adj
      draw_legend(
        legend = legend,
        legend_args = legend_args,
        by_dep = by_dep,
        lgnd_labs = lgnd_labs,
        type = type,
        pch = pch,
        lty = lty,
        lwd = lwd,
        col = col,
        bg = bg,
        gradient = by_continuous,
        cex = lgnd_cex,
        has_sub = has_sub,
        has_cap = has_cap,
        cap_text = cap,
        dynmar_title_mar = if (!is.null(dynmar_computed) && .outer_sides[3] && text_line_count(main) >= 1L) dynmar_computed[3] else NULL
      )
    } else {
      ## multi-legend case...
      prepare_legend_multi(settings)
      env2env(settings, environment(), c("legend_args", "lgby", "lgbub"))
      # draw multi-legend
      draw_multi_legend(list(lgby, lgbub), position = legend_args[["x"]])

    }

    has_legend = TRUE
    } else if (legend_args[["x"]] %in% c("none", "direct") && !isTRUE(add)) {
    omar = par("mar")
    ooma = par("oma")
    topmar_epsilon = 0.1

    # Catch to avoid recursive offsets, e.g. repeated tinyplot calls with
    # "bottom!" legend position.
    restore_margin_inner(ooma)
    # clean up for now
    rm(omar, ooma, topmar_epsilon)

    # Draw new plot
    plot.new()
  }


  #
  ## title and subtitle -----
  #

  direct_labels_flag = !isTRUE(add) && identical(legend_args[["x"]], "direct") &&
    !isTRUE(by_continuous) && !null_by
  if (identical(legend_args[["x"]], "direct") && !direct_labels_flag && !isTRUE(add)) {
    warning("legend=\"direct\" requires discrete groups via `by`. Falling back to no legend.")
  }

  if (!add) {
    # 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)) {
      if (has_legend && .outer_sides[3]) {
        dynmar_computed[3] = par("mar")[3] - .whtsbp[3]
      }
      par(mar = dynmar_computed + .whtsbp)
      if (!is.null(xlim) && !is.null(ylim)) {
        plot.window(xlim = xlim, ylim = ylim)
      }
    } else if (direct_labels_flag && !is.null(xlim) && !is.null(ylim)) {
      plot.window(xlim = xlim, ylim = ylim)
    }

    # Expand right margin for direct labels based on actual label overshoot
    if (direct_labels_flag && !is.null(xlim) && !is.null(ylim)) {
      usr_right = par("usr")[2]
      last_x = tapply(datapoints$x, datapoints$by, function(z) tail(z, 1))
      dl_nudge_x = legend_args[["nudge_x"]] %||% rep(0, length(last_x))
      dl_nudge_x = rep_len(dl_nudge_x, length(last_x))
      offset_usr = strwidth("m", units = "user") * 0.3
      label_widths = strwidth(lgnd_labs, units = "user")
      overshoots = (last_x + dl_nudge_x + offset_usr + label_widths) - usr_right
      max_overshoot = max(0, overshoots, na.rm = TRUE)
      if (max_overshoot > 0) {
        overshoot_lines = max_overshoot * par("pin")[1] / diff(par("usr")[1:2]) / par("csi")
        if (!is.null(dynmar_computed)) {
          dynmar_computed[4] = dynmar_computed[4] + overshoot_lines
          par(mar = dynmar_computed + .whtsbp)
        } else {
          cur_mar = par("mar")
          cur_mar[4] = cur_mar[4] + overshoot_lines
          par(mar = cur_mar)
        }
        plot.window(xlim = xlim, ylim = ylim)
      }
    }

    ann = as.logical(ann)
    if (ann) draw_title(
      main, sub, cap, xlab, ylab, legend, legend_args, opar,
      xlab_line_offset = if (!is.null(dynmar_computed)) .whtsbp_x_raw else 0,
      ylab_line_offset = if (!is.null(dynmar_computed)) .whtsbp_y_raw - max(0, .ymgp_shift) - .ylab_cex_shift else 0
    )
  }


  #
  ## facets: draw -----
  #

  # Two-phase plotting logic: First determine and draw all exterior elements 
  # (facet windows, axes, grid, etc.), then circle back to each facet and 
  # draw the interior elements (grouped points, lines, etc.)

  omar = NULL # Placeholder variable for now, which we re-assign as part of facet margins

  # placeholders for facet_window_args() call
  facet_newlines = facet_text = facet_rect = facet_font = facet_col = facet_bg = facet_border = NULL

  if (!is.null(facet) && !add) {
    if (is.null(omar)) omar = par("mar")

    # Grab some of the customizable facet args that we'll be using later
    facet_rect = FALSE
    facet_text = .tpar[["facet.cex"]]
    facet_font = .tpar[["facet.font"]]
    facet_col = .tpar[["facet.col"]]
    facet_bg = .tpar[["facet.bg"]]
    facet_border = .tpar[["facet.border"]]
    if (!is.null(facet.args)) {
      if (!is.null(facet.args[["cex"]])) facet_text = facet.args[["cex"]]
      if (!is.null(facet.args[["col"]])) facet_col = facet.args[["col"]]
      if (!is.null(facet.args[["font"]])) facet_font = facet.args[["font"]]
      if (!is.null(facet.args[["bg"]])) facet_bg = facet.args[["bg"]]
      if (!is.null(facet.args[["border"]])) facet_border = facet.args[["border"]]
    }
    if (!is.null(facet_bg) || !is.null(facet_border)) facet_rect = TRUE

    # Need extra adjustment to top margin if facet titles have "\n" newline
    # separator. (Note that we'll also need to take account for this in the
    # individual facet margins / gaps further below.)
    facet_newlines = lengths(gregexpr("\n", grep("\\n", facets, value = TRUE)))
    # if (length(facet_newlines)==0) facet_newlines = 0
    # omar[3] = omar[3] + max(facet_newlines)
    facet_newlines = ifelse(length(facet_newlines) == 0, 0, max(facet_newlines))
    omar[3] = omar[3] + facet_newlines * facet_text
    # apply the changes
    par(mar = omar)
  }

  # Now draw the individual facet windows (incl. axes, grid lines, and facet titles)
  # Will be skipped if adding to an existing plot; see ?facet

  dl_overshoot = 0
  if (direct_labels_flag && nfacets > 1) {
    dl_label_lines = max(strwidth(lgnd_labs, "inches")) / par("csi")
    dl_overshoot = dl_label_lines * cex_fct_adj * 0.5
  }

  facet_window_args = recordGraphics(
    draw_facet_window(
      add = add,
      # facet-specific args
      cex_fct_adj = cex_fct_adj,
      facet.args = facet.args,
      facet_newlines = facet_newlines, facet_font = facet_font,
      facet_rect = facet_rect, facet_text = facet_text,
      facet_col = facet_col, facet_bg = facet_bg, facet_border = facet_border,
      facet = facet,
      facets = facets, ifacet = ifacet,
      nfacets = nfacets, nfacet_cols = nfacet_cols, nfacet_rows = nfacet_rows,
      # axes args
      axes = axes, flip = flip, frame.plot = frame.plot,
      oxaxis = oxaxis, oyaxis = oyaxis,
      xlabs = xlabs, xlim = xlim, null_xlim = null_xlim, xaxt = xaxt, xaxs = xaxs, xaxb = xaxb, xaxl = xaxl,
      ylabs = ylabs, ylim = ylim, null_ylim = null_ylim, yaxt = yaxt, yaxs = yaxs, yaxb = yaxb, yaxl = yaxl,
      rev_x = rev_x, rev_y = rev_y,
      asp = asp, log = log,
      # other args (in approx. alphabetical + group ordering)
      dots = dots,
      draw = draw,
      grid = grid,
      has_legend = has_legend,
      main = main,
      sub = sub,
      cap = cap,
      type = type,
      xlab = xlab,
      x = x, xmax = xmax, xmin = xmin,
      ylab = ylab,
      y = y, ymax = ymax, ymin = ymin,
      tpars = tpars,
      dynmar_computed = dynmar_computed,
      dl_overshoot = dl_overshoot
    ),
    list = list(
      add = add,
      cex_fct_adj = cex_fct_adj,
      facet.args = facet.args,
      facet_newlines = facet_newlines, facet_font = facet_font,
      facet_rect = facet_rect, facet_text = facet_text,
      facet_col = facet_col, facet_bg = facet_bg, facet_border = facet_border,
      facet = datapoints$facet,
      facets = facets, ifacet = ifacet,
      nfacets = nfacets, nfacet_cols = nfacet_cols, nfacet_rows = nfacet_rows,
      axes = axes, flip = flip, frame.plot = frame.plot,
      oxaxis = oxaxis, oyaxis = oyaxis,
      xlabs = xlabs, xlim = xlim, null_xlim = null_xlim, xaxt = xaxt, xaxs = xaxs, xaxb = xaxb, xaxl = xaxl,
      ylabs = ylabs, ylim = ylim, null_ylim = null_ylim, yaxt = yaxt, yaxs = yaxs, yaxb = yaxb, yaxl = yaxl,
      rev_x = rev_x, rev_y = rev_y,
      asp = asp, log = log,
      dots = dots,
      draw = draw,
      grid = grid,
      has_legend = has_legend,
      main = main,
      sub = sub,
      cap = cap,
      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
      dynmar_computed = dynmar_computed,
      dl_overshoot = dl_overshoot
    ),
    getNamespace("tinyplot")
  )
  list2env(facet_window_args, environment())

  #
  ## split and draw datapoints -----
  #

  # Finally, we can draw all of the plot elements (points, lines, etc.)
  # We'll do this via a nested loops:
  #  1) Outer loop over facets
  #  2) Inner loop over groups
  if (!is.null(datapoints$facet)) {
    split_data = split(datapoints, datapoints$facet)
    split_data = lapply(split_data, as.list)
  } else {
    split_data = list(as.list(datapoints))
  }

  if (direct_labels_flag) .dl_info = lapply(seq_along(split_data), function(x) vector("list", ngrps))

  ## Outer loop over the facets
  for (i in seq_along(split_data)) {
    # Split group-level data again to grab any "by" groups
    idata = split_data[[i]]
    iby = idata[["by"]]
    if (!null_by) { ## maybe all(iby=="")
      if (isTRUE(by_continuous)) {
        idata[["col"]] = col[round(rescale_num(idata$by, from = range(datapoints$by), to = c(1, 100)))]
        idata[["bg"]] = bg[round(rescale_num(idata$by, from = range(datapoints$by), to = c(1, 100)))]
        idata = list(idata)
      } else {
        idata = lapply(idata, split, iby)
        idata = do.call(function(...) Map("list", ...), idata)
      }
    } else {
      idata = list(idata)
      if (isTRUE(by_continuous)) {
        if (length(col) != 1) {
          idata[[1]][["col"]] = col[round(rescale_num(by, to = c(1, 100)))]
        } else {
          idata[[1]][["col"]] = col
        }
        if (length(bg) != 1) {
          idata[[1]][["bg"]] = bg[round(rescale_num(by, to = c(1, 100)))]
        } else {
          idata[[1]][["bg"]] = bg
        }
      }
    }
    
    # Set the facet "window" manually
    # See: https://github.com/grantmcdermott/tinyplot/issues/65
    if (nfacets > 1) {
      mfgi = ceiling(i / nfacet_cols)
      mfgj = i %% nfacet_cols
      if (mfgj == 0) mfgj = nfacet_cols
      par(mfg = c(mfgi, mfgj))

      # For free facets, we need to reset par(usr) based extent of that
      # particular facet... which we calculated and saved to the .fusr env var
      # (list) back in draw_facet_window()
      if (isTRUE(facet.args[["free"]])) {
        fusr = get(".fusr", envir = get(".tinyplot_env", envir = parent.env(environment())))
        par(usr = fusr[[i]])
      }
    }


    ## Inner loop over the "by" groupings
    for (ii in seq_along(idata)) {
      icol = col[ii]
      ibg = bg[ii]
      ipch = pch[ii]
      ilty = lty[ii]
      ilwd = lwd[ii]
      icex = if (bubble) idata[[ii]][["cex"]] else cex[[ii]]
      
      ix = idata[[ii]][["x"]]
      iy = idata[[ii]][["y"]]
      iz = idata[[ii]][["z"]]
      ixmin = idata[[ii]]$xmin
      ixmax = idata[[ii]]$xmax
      iymin = idata[[ii]]$ymin
      iymax = idata[[ii]]$ymax
      ilabels = idata[[ii]][["labels"]]

      if (isTRUE(by_continuous)) {
        icol = idata[[ii]]$col
        ibg = idata[[ii]]$bg
      }

      # empty plot flag
      empty_plot = FALSE
      if (isTRUE(empty) || isTRUE(type == "n") || ((length(ix) == 0) && !(type %in% c("histogram", "hist", "rect", "segments", "spineplot")))) {
        empty_plot = TRUE
      }

      # Draw the individual plot elements...
      if (!isTRUE(empty_plot)) {
        if (is.null(type_draw)) {
          type_draw = switch(type,
            "ribbon" = type_ribbon()$draw,
            "polygon" = type_polygon()$draw,
            "rect" = type_rect()$draw,
            "p" = ,
            "points" = type_points()$draw,
            "l" = ,
            "o" = ,
            "b" = ,
            "c" = ,
            "h" = ,
            "s" = ,
            "S" = type_lines(type = type)$draw
          )
        }
        type_draw(
          ibg = ibg,
          icol = icol,
          ilty = ilty,
          ilwd = ilwd,
          ipch = ipch,
          ix = ix,
          ixmax = ixmax,
          ixmin = ixmin,
          iy = iy,
          iymax = iymax,
          iymin = iymin,
          ilabels = ilabels,
          iz = iz,
          # cex = cex,
          icex = icex,
          dots = dots,
          type = type,
          x_by = x_by,
          by_continuous = by_continuous,
          iby = ii,
          ifacet = i,
          facet_by = facet_by,
          data_facet = idata,
          ngrps = ngrps,
          nfacets = nfacets,
          flip = flip,
          type_info = type_info,
          facet_window_args = facet_window_args
        )
      }
      if (direct_labels_flag && !empty_plot && length(ix) > 0) {
        .dl_info[[i]][[ii]] = list(x = tail(ix, 1), y = tail(iy, 1), col = icol)
      }
    }
  }

  if (direct_labels_flag) {
    dl_labs = lgnd_labs
    repel = legend_args[["repel"]]
    has_nudge = !is.null(legend_args[["nudge_x"]]) || !is.null(legend_args[["nudge_y"]])
    if (has_nudge && !is.null(repel) && !isFALSE(repel)) {
      warning("Direct labels: both `nudge` and `repel` specified. Using `nudge`.", call. = FALSE)
      repel = FALSE
    }
    nudge_x = rep(0, ngrps)
    nudge_y = rep(0, ngrps)
    if (has_nudge) {
      nudge_x = legend_args[["nudge_x"]] %||% rep(0, ngrps)
      nudge_y = legend_args[["nudge_y"]] %||% rep(0, ngrps)
      if (!is.null(names(nudge_x))) {
        idx = match(lgnd_labs, names(nudge_x))
        nudge_x = ifelse(is.na(idx), 0, nudge_x[idx])
      }
      if (!is.null(names(nudge_y))) {
        idx = match(lgnd_labs, names(nudge_y))
        nudge_y = ifelse(is.na(idx), 0, nudge_y[idx])
      }
      nudge_x = rep_len(nudge_x, ngrps)
      nudge_y = rep_len(nudge_y, ngrps)
    }

    for (fi in seq_along(.dl_info)) {
      if (nfacets > 1) {
        mfgi = ceiling(fi / nfacet_cols)
        mfgj = fi %% nfacet_cols
        if (mfgj == 0) mfgj = nfacet_cols
        par(mfg = c(mfgi, mfgj))
        if (isTRUE(facet.args[["free"]])) {
          fusr = get(".fusr", envir = get(".tinyplot_env", envir = parent.env(environment())))
          par(usr = fusr[[fi]])
        }
      }

      fi_info = .dl_info[[fi]]
      dl_x = vapply(fi_info, function(d) if (!is.null(d)) d$x else NA_real_, numeric(1))
      dl_y = vapply(fi_info, function(d) if (!is.null(d)) d$y else NA_real_, numeric(1))
      dl_x = dl_x + nudge_x
      dl_y = dl_y + nudge_y

      if (!is.null(repel) && !isFALSE(repel)) {
        min_gap = if (isTRUE(repel)) 0 else as.numeric(repel)
        dl_y = repel_text(
          x = rep(0, length(dl_y)), y = dl_y,
          widths = rep(0, length(dl_y)),
          heights = strheight(dl_labs, units = "user"),
          min_gap = min_gap, axis = "y"
        )[["y"]]
      }

      for (k in seq_along(fi_info)) {
        if (!is.null(fi_info[[k]])) {
          text(dl_x[k], dl_y[k], labels = dl_labs[k],
               col = fi_info[[k]]$col, pos = 4, offset = 0.3, xpd = NA)
        }
      }
    }
  }

  #
  ## save end pars for possible recall later -----
  #

  if (!add) {
    # Capture device and usr before recordGraphics (in current plot context)
    current_dev = dev.cur()
    current_usr = if (isTRUE(settings$flip)) par("usr")[c(3,4,1,2)] else par("usr")
    
    # Store usr and dev for validating layer alignment
    assign("usr_orig", current_usr, envir = get(".tinyplot_env", envir = parent.env(environment())))
    assign("dev_orig", current_dev, envir = get(".tinyplot_env", envir = parent.env(environment())))
    
    recordGraphics(
      {
        apar = par(no.readonly = TRUE)
        set_saved_par(when = "after", apar)
      },
      list = list(),
      env = getNamespace('tinyplot')
    )
  }

}




#' @rdname tinyplot
#' @importFrom stats as.formula model.frame terms
#' @export
tinyplot.formula = function(
    x = NULL,
    data = parent.frame(),
    facet = NULL,
    facet.args = NULL,
    type = NULL,
    xmin = NULL,
    xmax = NULL,
    ymin = NULL,
    ymax = NULL,
    weights = NULL,
    labels = NULL,
    xlim = NULL,
    ylim = NULL,
    # log = "",
    main = NULL,
    sub = NULL,
    cap = NULL,
    xlab = NULL,
    ylab = NULL,
    ann = par("ann"),
    axes = TRUE,
    frame.plot = NULL,
    asp = NA,
    grid = NULL,
    pch = NULL,
    col = NULL,
    lty = NULL,
    lwd = NULL,
    restore.par = FALSE,
    formula = NULL,
    subset = NULL,
    na.action = NULL,
    drop.unused.levels = TRUE,
    ...) {
  ## formula for variables must be specified through 'x' or 'formula' but not both
  if (is.null(x)) {
    if (missing(formula)) {
      stop("plot formula must be specified by either 'x' or 'formula' argument")
    }
  } else {
    if (missing(formula)) {
      formula = x
    } else {
      warning("only one of the arguments 'x' and 'formula' should be specified, defaulting to the 'formula' argument")
    }
  }

  ## placeholder for legend title
  legend_args = list(x = NULL)

  ## turn facet into a formula if it does not evaluate successfully
  if (inherits(try(facet, silent = TRUE), "try-error")) {
    facet = as.formula(paste("~", deparse(substitute(facet))))
    environment(facet) = environment(formula)
  }

  ## process all formulas
  tf = tinyformula(formula, facet)

  ## set up model frame
  m = match.call(expand.dots = FALSE)
  m = m[c(1L, match(c("formula", "data", "subset", "na.action", "drop.unused.levels", "xmin", "xmax", "ymin", "ymax", "weights", "labels"), names(m), 0L))]
  m$formula = tf$full
  ## need stats:: for non-standard evaluation
  m[[1L]] = quote(stats::model.frame)
  mf = eval.parent(m)

  ## extract x (if any)
  x = tinyframe(tf$x, mf)
  if (!is.null(x) && ncol(x) > 0L) {
    xnam = names(x)[[1L]]
    if (length(names(x)) > 1L) warning(paste("formula should specify at most one x-variable, using:", xnam),
    "\nif you want to use arithmetic operators, make sure to wrap them inside I()")
    x = x[[xnam]]
  } else {
    x = NULL
    xnam = NULL
  }

  ## extract y (if any)
  y = tinyframe(tf$y, mf)
  if (!is.null(y) && ncol(y) > 0L) {
    ynam = names(y)[[1L]]
    if (length(names(y)) > 1L) warning(paste("formula should specify at most one y-variable, using:", ynam),
    "\nif you want to use arithmetic operators, make sure to wrap them inside I()")
    y = y[[ynam]]
  } else {
    y = NULL
    ynam = NULL
  }

  ## extract by (if any)
  by = tinyframe(tf$by, mf)
  if (!is.null(by) && ncol(by) > 0L) {
    bynam = names(by)
    by = if (length(bynam) == 1L) by[[bynam]] else interaction(by, sep = ":")
  } else {
    by = NULL
  }

  ## extract x/y facet (if formula)
  if (!is.null(tf$xfacet) || !is.null(tf$yfacet)) {
    ## xfacet/yfacet can be either:
    ## - no formula: NULL
    ## - formula without variables: empty (zero-column) data
    ## - formula with variable(s): data frame
    xfacet = tinyframe(tf$xfacet, mf)
    yfacet = tinyframe(tf$yfacet, mf)
    xtype = if (is.null(xfacet)) "none" else if (ncol(xfacet) == 0L) "empty" else "data"
    ytype = if (is.null(yfacet)) "none" else if (ncol(yfacet) == 0L) "empty" else "data"
    
    ## turn data frame (if specified) into a single factor
    if (xtype == "data") xfacet = if (ncol(xfacet) == 1L) xfacet[[1L]] else interaction(xfacet, sep = ":")
    if (ytype == "data") yfacet = if (ncol(yfacet) == 1L) yfacet[[1L]] else interaction(yfacet, sep = ":")

    ## set up actual facet, potentially with grid information
    if (xtype %in% c("none", "empty") && ytype %in% c("none", "empty")) {
      facet = NULL
    } else {
      if (xtype %in% c("none", "empty")) {
        facet = yfacet
        if (xtype == "empty") {
          if (is.null(facet.args)) facet.args = list()
          if (is.null(facet.args[["nrow"]])) facet.args[["nrow"]] = length(unique(yfacet))
        }
      } else if (ytype %in% c("none", "empty")) {
        facet = xfacet
        if (ytype == "empty") {
          if (is.null(facet.args)) facet.args = list()
          if (is.null(facet.args[["nrow"]])) facet.args[["nrow"]] = 1L
        }
      } else {
        facet = interaction(xfacet, yfacet, sep = "~")
        attr(facet, "facet_grid") = TRUE
        attr(facet, "facet_nrow") = length(unique(yfacet))
      }
    }
  }

  ## nice axis and legend labels
  dens_type = !is.null(type) && (is.atomic(type) && identical(type, "density")) || (!is.atomic(type) && identical(type$name, "density"))
  hist_type = !is.null(type) && (is.atomic(type) && type %in% c("hist", "histogram")) || (!is.atomic(type) && identical(type$name, "histogram"))
  barp_type = !is.null(type) &&  (is.atomic(type) && identical(type, "barplot")) || (!is.atomic(type) && identical(type$name, "barplot"))
  if (is.null(x) && is.null(y)) {
    # Exception: both x and y NULL (e.g., ~ 0 with type = "segments").
    # Build labels from xmin/xmax/ymin/ymax names in the original call (m),
    # since deparse(substitute()) in the default method would see mf[["..."]].
    if (is.null(xlab) && !is.null(m[["xmin"]]) && !is.null(m[["xmax"]])) {
      xlab = sprintf("[%s, %s]", deparse1(m[["xmin"]]), deparse1(m[["xmax"]]))
    }
    if (is.null(ylab) && !is.null(m[["ymin"]]) && !is.null(m[["ymax"]])) {
      ylab = sprintf("[%s, %s]", deparse1(m[["ymin"]]), deparse1(m[["ymax"]]))
    }
  } else if (is.null(x) && !is.null(y)) {
    # Exception: univariate y ~ 1 formulas. sanitize_type() will swap x/y and
    # infer the type (histogram or barplot). Set xlab from the variable name
    # and let sanitize_xylab() determine ylab after the type is known.
    if (is.null(xlab)) xlab = ynam
  } else if (dens_type) {
    # if (is.null(ylab)) ylab = "Density" ## rather assign ylab as part of internal type_density() logic
    if (is.null(xlab)) xlab = xnam
  } else if (hist_type) {
    # if (is.null(ylab)) ylab = "Frequency" ## rather assign ylab as part of internal type_histogram() logic
    if (is.null(xlab)) xlab = xnam
  } else if (is.null(y)) {
    if (is.factor(x) || is.character(x) || barp_type) {
      if (is.null(xlab)) xlab = xnam
    } else {
      if (is.null(ylab)) ylab = xnam
      if (is.null(xlab)) xlab = "Index"
    }
  } else {
    if (is.null(ylab)) ylab = ynam
    if (is.null(xlab)) xlab = xnam
  }
  if (!is.null(by)) {
    legend_args[["title"]] = if (length(bynam) == 1L) bynam else sprintf("interaction(%s)", paste(bynam, collapse = ", "))
  }

  tinyplot.default(
    x = x, y = y, by = by,
    facet = facet, facet.args = facet.args,
    data = data,
    type = type,
    xmin = mf[["(xmin)"]],
    xmax = mf[["(xmax)"]],
    ymin = mf[["(ymin)"]],
    ymax = mf[["(ymax)"]],
    weights = mf[["(weights)"]],
    labels = mf[["(labels)"]],
    xlim = xlim,
    ylim = ylim,
    # log = "",
    main = main,
    sub = sub,
    cap = cap,
    xlab = xlab,
    ylab = ylab,
    ann = ann,
    axes = axes,
    frame.plot = frame.plot,
    asp = asp,
    grid = substitute(grid), # issue #193
    legend_args = legend_args,
    pch = pch,
    col = col,
    lty = lty,
    lwd = lwd,
    restore.par = restore.par,
    ...
  )
}

#' @rdname tinyplot
#' @export
tinyplot.density = function(
    x = NULL,
    type = c("l", "area"),
    ...) {
  
  dots = list(...)
  
  if (!is.null(dots[["by"]]) || !is.null(dots[["facet"]])) {
    stop(
      '\nGrouped and/or faceted plots are no longer supported with the tinyplot.density() method. ',
      '\nPlease use the dedicated type argument instead, e.g. `tinyplot(..., type = "density")`. See `?type_density` for details.',
      '\n\nThis breaking change was introduced in tinyplot v0.3.0.'
    )
  }
  
  type = match.arg(type)
  
  ## override if bg = "by"
  if (!is.null(dots[["bg"]]) || !is.null(dots[["fill"]])) type = "area"
  
  if (inherits(x, "density")) {
    object = x
    # legend_args = list(x = NULL)
    # # Grab by label to pass on legend title to tinyplot.default
    # legend_args[["title"]] = deparse(substitute(by))
  } else {
    ## An internal catch for non-density objects that were forcibly
    ## passed to tinyplot.density (e.g., via a one-side formula)
    if (anyNA(x)) {
      x = na.omit(x)
      x = as.numeric(x)
    }
    object = density(x)
  }
  
  x = object$x
  y = object$y
  
  if (type == "area") {
    ymin = rep(0, length(y))
    ymax = y
    # # set extra legend params to get bordered boxes with fill
    # legend_args[["x.intersp"]] = 1.25
    # legend_args[["lty"]] = 0
    # legend_args[["pt.lwd"]] = 1
  }
  
  # splice in change arguments
  dots[["x"]] = x
  dots[["y"]] = y
  dots[["type"]] = type
  
  ## axes range
  if (is.null(dots[["xlim"]])) dots[["xlim"]] = range(x)
  if (is.null(dots[["ylim"]])) dots[["ylim"]] = range(y)
  
  ## nice labels and titles
  if (is.null(dots[["ylab"]])) dots[["ylab"]] = "Density"
  if (is.null(dots[["xlab"]])) dots[["xlab"]] = paste0("N = ", object$n, "   Bandwidth = ", sprintf("%.4g", object$bw))
  if (is.null(dots[["main"]])) dots[["main"]] = paste0(paste(object$call, collapse = "(x = "), ")")
  
  do.call(tinyplot.default, args = dots)
  
}


#' @export
#' @name plt
#' @rdname tinyplot
plt = tinyplot
