Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
## [Unreleased]

### Added

- Text layers now have text boxes. The presence of the `box` aesthetic
determines whether text boxes get drawn and additional settings `padding`,
`radius` and `linewidth` style the box.

### Changed

- The reader/dialect layer was refactored around a central dialect registry with
Expand Down
26 changes: 24 additions & 2 deletions doc/syntax/layer/type/text.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ The following aesthetics are recognised by the text layer.
* `label` The text to display.

### Optional
* `stroke` The colour of the contour lines of glyphs. Typically kept blank.
* `stroke` The colour of the contour lines of glyphs or text box borders. Typically kept blank.
* `fill` The colour of the glyphs.
* `colour` Shorthand for setting `stroke` and `fill` simultaneously.
* `box` The colour of the text box. Setting this aesthetic switches on text boxes. Setting `null` (the default) does not draw text boxes.
* `opacity` The opacity of the fill colour.
* `typeface` The typeface to style the lettering.
* `fontsize` The size of the text in points.
Expand All @@ -39,6 +40,9 @@ The following aesthetics are recognised by the text layer.
* `aggregate` Aggregation functions to apply per group:
* `null` apply no group aggregation (default).
* A single string or an array of strings. See an overview of aggregation function in [the `DRAW` documentation](../../clause/draw.qmd#aggregate) and more information in the *Data transformation* section below.
* `padding` In text boxes, space around the text inside box borders in points. Defaults to `4`.
* `radius` In text boxes, rounded corner radius in points. Defaults to `0`.
* `linewidth` In text boxes, size of border stroke in points. Defaults to `1`.

### Format
The `format` setting can take a string that will be used in formatting the `label` aesthetic.
Expand Down Expand Up @@ -130,7 +134,16 @@ DRAW text
SETTING format => '{:Title} *island*'
```

The 'stroke' aesthetic is applied to the outline of the text.
When the `box` aesthetic is used, we're drawing text boxes.

```{ggsql}
VISUALISE bill_len AS x, bill_dep AS y FROM ggsql:penguins
DRAW text
MAPPING island AS label, species AS box
SETTING colour => 'white'
```

Without text boxes, the 'stroke' aesthetic is applied to the outline of the text.

```{ggsql}
SELECT 1 as x, 1 as y
Expand All @@ -139,6 +152,15 @@ DRAW text
SETTING fontsize => 30, stroke => 'red'
```

Text boxes solve a similar problem as using outlines, so 'stroke' is applied to text boxes when appropriate.

```{ggsql}
SELECT 1 as x, 1 as y
VISUALISE x, y, 'My Label' AS label
DRAW text
SETTING fontsize => 30, stroke => 'red', box => 'white'
```

Labelling precomputed bars with the data value.

```{ggsql}
Expand Down
22 changes: 22 additions & 0 deletions src/plot/layer/geom/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ impl GeomTrait for Text {
("label", DefaultAestheticValue::Required),
("stroke", DefaultAestheticValue::Null),
("fill", DefaultAestheticValue::String("black")),
("box", DefaultAestheticValue::Null),
("opacity", DefaultAestheticValue::Number(1.0)),
("typeface", DefaultAestheticValue::Null),
("fontsize", DefaultAestheticValue::Number(11.0)),
Expand Down Expand Up @@ -65,6 +66,27 @@ impl GeomTrait for Text {
default: DefaultParamValue::Boolean(true),
constraint: ParamConstraint::boolean(),
},
// Box geometry settings, in points; only relevant in box-mode
// (see `box` aesthetic).
ParamDefinition {
name: "radius",
default: DefaultParamValue::Number(0.0),
constraint: ParamConstraint::number(NumberConstraint::min(0.0)),
},
// Hephaestus only supports uniform padding, so this is a single
// number rather than an array.
ParamDefinition {
name: "padding",
default: DefaultParamValue::Number(4.0),
constraint: ParamConstraint::number(NumberConstraint::min(0.0)),
},
ParamDefinition {
name: "linewidth",
default: DefaultParamValue::Number(1.0),
constraint: ParamConstraint::number(NumberConstraint::min(0.0)),
},
// No `linetype` setting yet: hephaestus has no dash channel for
// background-rect strokes, so a setting would silently no-op.
super::types::AGGREGATE_PARAM,
];
PARAMS
Expand Down
5 changes: 4 additions & 1 deletion src/plot/scale/colour.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ pub fn color_to_hex(value: &str) -> Result<String, String> {

/// Check if an aesthetic name is color-related.
pub fn is_color_aesthetic(aesthetic: &str) -> bool {
matches!(aesthetic, "color" | "col" | "colour" | "fill" | "stroke")
matches!(
aesthetic,
"color" | "col" | "colour" | "fill" | "stroke" | "box"
)
}

// =============================================================================
Expand Down
2 changes: 1 addition & 1 deletion src/plot/scale/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ pub fn gets_default_scale(aesthetic: &str) -> bool {
matches!(
aesthetic,
// Color aesthetics (color/colour/col already split to fill/stroke)
"fill" | "stroke"
"fill" | "stroke" | "box"
// Size aesthetics
| "size" | "linewidth"
// Dimension aesthetics
Expand Down
2 changes: 1 addition & 1 deletion src/plot/scale/palettes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2126,7 +2126,7 @@ pub fn lookup_palette(aesthetic: &str, name: &str) -> Result<Vec<ArrayElement>,
let palette = match aesthetic {
"shape" => get_shape_palette(name),
"linetype" => get_linetype_palette(name),
"color" | "fill" | "stroke" => get_color_palette(name),
"color" | "fill" | "stroke" | "box" => get_color_palette(name),
_ => {
return Err(
"Palettes are only valid for color, fill, stroke, shape, and linetype aesthetics"
Expand Down
2 changes: 1 addition & 1 deletion src/plot/scale/scale_type/binned.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ impl ScaleTypeTrait for Binned {
// Return full palette - sizing/interpolation is done in resolve_output_range()
match aesthetic {
// Note: "color"/"colour" already split to fill/stroke before scale resolution
"stroke" | "fill" => {
"stroke" | "fill" | "box" => {
let palette = palettes::get_color_palette("sequential")
.ok_or_else(|| "Default color palette 'sequential' not found".to_string())?;
Ok(Some(
Expand Down
2 changes: 1 addition & 1 deletion src/plot/scale/scale_type/continuous.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ impl ScaleTypeTrait for Continuous {

match aesthetic {
// Note: "color"/"colour" already split to fill/stroke before scale resolution
"stroke" | "fill" => {
"stroke" | "fill" | "box" => {
let palette = palettes::get_color_palette("sequential")
.ok_or_else(|| "Default color palette 'sequential' not found".to_string())?;
Ok(Some(
Expand Down
2 changes: 1 addition & 1 deletion src/plot/scale/scale_type/discrete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ impl ScaleTypeTrait for Discrete {
// Return full palette - sizing is done in resolve_output_range()
match aesthetic {
// Note: "color"/"colour" already split to fill/stroke before scale resolution
"fill" | "stroke" => {
"fill" | "stroke" | "box" => {
let palette = palettes::get_color_palette("ggsql")
.ok_or_else(|| "Default color palette 'ggsql' not found".to_string())?;
Ok(Some(
Expand Down
2 changes: 1 addition & 1 deletion src/plot/scale/scale_type/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1620,7 +1620,7 @@ pub(crate) fn size_output_range(
}

if let Some(OutputRange::Array(ref arr)) = scale.output_range.clone() {
if matches!(aesthetic, "fill" | "stroke") && arr.len() >= 2 {
if matches!(aesthetic, "fill" | "stroke" | "box") && arr.len() >= 2 {
// Color interpolation using Oklab
let hex_strs: Vec<&str> = arr
.iter()
Expand Down
2 changes: 1 addition & 1 deletion src/plot/scale/scale_type/ordinal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ impl ScaleTypeTrait for Ordinal {
// Colors use "sequential" (like Continuous) since ordinal has inherent ordering
// Other aesthetics same as Discrete
match aesthetic {
"stroke" | "fill" => {
"stroke" | "fill" | "box" => {
let palette = palettes::get_color_palette("sequential")
.ok_or_else(|| "Default color palette 'sequential' not found".to_string())?;
Ok(Some(
Expand Down
17 changes: 17 additions & 0 deletions src/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,23 @@ mod tests {
);
}

#[test]
fn test_validate_box_aesthetic_on_text() {
// box is the text geom's box-background channel; it should validate
// both mapped and as a constant
for query in [
"SELECT 1 as x, 2 as y, 'a' as lbl, 'c' as cat VISUALISE DRAW text MAPPING x AS x, y AS y, lbl AS label, cat AS box",
"SELECT 1 as x, 2 as y, 'a' as lbl VISUALISE DRAW text MAPPING x AS x, y AS y, lbl AS label SETTING box => 'white'",
] {
let validated = validate(query).unwrap();
assert!(
validated.valid(),
"box should be accepted on text geom: {:?}",
validated.errors()
);
}
}

#[test]
fn test_validate_colour_spelling() {
// British spelling 'colour' should work (normalized by parser to 'color')
Expand Down
2 changes: 1 addition & 1 deletion src/writer/hephaestus/compose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ pub fn build_composition(
// its position channels to these names, giving fixed-scale faceting.
for scale in &spec.scales {
let kind = match scale.aesthetic.as_str() {
"fill" | "stroke" => scales::RangeKind::Color,
"fill" | "stroke" | "box" => scales::RangeKind::Color,
"shape" => scales::RangeKind::Shape,
"linetype" => scales::RangeKind::Linetype,
// The text geom's font aesthetics: a scale over them resolves a
Expand Down
51 changes: 48 additions & 3 deletions src/writer/hephaestus/geom/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,30 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> {
// family is not "use the default" but a font lookup that misses. The glyph
// outline's width is hephaestus's theme default, as ggsql's text geom has no
// `linewidth` aesthetic.
wire_material(&mut b, &material(), plot, ctx, LegendKind::Text)?;
let boxed = is_textbox(layer);
// `SETTING box => true` declares a box without naming a colour; the
// material path cannot read a boolean literal as a colour, so the white
// default is bound directly here.
if let Some(AestheticValue::Literal(ParameterValue::Boolean(true))) = layer.mappings.get("box")
{
b.set("bg_fill", Raw(vec![rgb8(255, 255, 255); n]));
}
wire_material(&mut b, &material(boxed), plot, ctx, LegendKind::Text)?;

// Box geometry: layer parameters rather than aesthetics, bound as
// constants like `offset` — but only in box-mode, where hephaestus draws
// the rect they describe. `linetype` has no hephaestus channel yet.
if boxed {
for (param, channel) in [
("radius", "bg_corner_radius"),
("padding", "bg_padding"),
("linewidth", "bg_linewidth"),
] {
if let Some(ParameterValue::Number(v)) = layer.parameters.get(param) {
b.set(channel, Raw(vec![*v; n]));
}
}
}

// Justification needs conversion no `RangeKind` covers, so it is resolved per
// row here: a mapped column, else the layer's literal repeated, else centred.
Expand Down Expand Up @@ -89,15 +112,25 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> {
/// aesthetic hephaestus's `Text` key consumes belongs here — the face a layer
/// sets is as much part of what a `fontsize` swatch describes as its colour is.
/// Only justification is left out: the key centres its glyph in the cell.
fn material() -> [MaterialSpec; 8] {
///
/// `stroke`'s channel depends on box-mode: bare text outlines glyphs
/// (`text_stroke`), boxed text outlines the box (`bg_stroke`). `box` itself
/// defaults to `None` — unset means hephaestus draws no background rect.
fn material(boxed: bool) -> [MaterialSpec; 9] {
[
MaterialSpec::new(
"fill",
"fill",
RangeKind::Color,
MatDefault::Color(rgb8(0, 0, 0)),
),
MaterialSpec::new("stroke", "text_stroke", RangeKind::Color, MatDefault::None),
MaterialSpec::new(
"stroke",
if boxed { "bg_stroke" } else { "text_stroke" },
RangeKind::Color,
MatDefault::None,
),
MaterialSpec::new("box", "bg_fill", RangeKind::Color, MatDefault::None),
MaterialSpec::new(
"fontsize",
"size",
Expand Down Expand Up @@ -126,6 +159,18 @@ fn material() -> [MaterialSpec; 8] {
]
}

/// Box-mode: the layer draws boxes when `box` is declared with a non-null
/// value, mapped or set. An explicit null (`box => null`) is the opt-out and
/// counts as undeclared, matching the null-sentinel convention.
fn is_textbox(layer: &crate::Layer) -> bool {
match layer.mappings.get("box") {
None => false,
Some(AestheticValue::Literal(ParameterValue::Null)) => false,
Some(AestheticValue::Literal(ParameterValue::Boolean(false))) => false,
Some(_) => true,
}
}

/// The layer's `offset` parameter as `(dx, dy)` in points. A bare number offsets
/// both axes; a two-element array gives them separately.
fn offset(layer: &crate::Layer) -> (f64, f64) {
Expand Down
46 changes: 46 additions & 0 deletions src/writer/hephaestus/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1226,6 +1226,39 @@ mod tests {
);
}

#[test]
fn renders_text_boxed() {
// Box-mode via a constant: white background, border from stroke,
// geometry settings applied.
assert_renders(
"SELECT 1 AS x, 2 AS y, 'a' AS lbl UNION ALL SELECT 2, 3, 'b' \
VISUALISE x AS x, y AS y, lbl AS label DRAW text \
SETTING box => 'white', stroke => 'black', radius => 3, padding => 5, linewidth => 2",
);
}

#[test]
fn renders_text_boxed_by_group() {
// Box-mode via a mapped column: the box fill flows through a colour
// scale with a legend.
assert_renders(
"SELECT 1 AS x, 2 AS y, 'a' AS lbl, 'one' AS g \
UNION ALL SELECT 2, 3, 'b', 'two' \
VISUALISE x AS x, y AS y, lbl AS label, g AS box DRAW text",
);
}

#[test]
fn renders_text_box_opt_out() {
// `box => null` is the explicit opt-out: bare text, and stroke falls
// back to outlining glyphs rather than a box border.
assert_renders(
"SELECT 1 AS x, 2 AS y, 'a' AS lbl UNION ALL SELECT 2, 3, 'b' \
VISUALISE x AS x, y AS y, lbl AS label DRAW text \
SETTING box => null, stroke => 'white'",
);
}

#[test]
fn renders_titled_plot() {
// Title, subtitle and caption all sit on the composition, above/below the
Expand Down Expand Up @@ -1358,6 +1391,19 @@ mod svg_text {
texts(svg).iter().any(|t| t == label)
}

#[test]
fn boxed_text_draws_a_padded_box_behind_the_label() {
// The box is one rounded rect per label (radius curves in the path),
// filled and bordered as declared, drawn before the glyph path.
let s = svg("SELECT 1 AS x, 2 AS y, 'hi' AS lbl \
VISUALISE x AS x, y AS y, lbl AS label DRAW text \
SETTING box => 'red', stroke => 'blue', radius => 3");
let fill_at = s.find("fill=\"#ff0000\"").expect("box fill missing");
assert!(s.contains("stroke=\"#0000ff\""), "box border missing");
let label_at = s.find(">hi<").expect("label missing");
assert!(fill_at < label_at, "box should draw before the label");
}

#[test]
fn tick_labels_are_the_ones_ggsql_resolved() {
// ggsql's own break spacing and number formatting: the trailing `.0`
Expand Down
Loading