diff --git a/CHANGELOG.md b/CHANGELOG.md index 82477a5fc..5b89d3af5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/doc/syntax/layer/type/text.qmd b/doc/syntax/layer/type/text.qmd index c27a3213b..9ba97667d 100644 --- a/doc/syntax/layer/type/text.qmd +++ b/doc/syntax/layer/type/text.qmd @@ -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. @@ -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. @@ -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 @@ -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} diff --git a/src/plot/layer/geom/text.rs b/src/plot/layer/geom/text.rs index d1eacde97..9b672ceea 100644 --- a/src/plot/layer/geom/text.rs +++ b/src/plot/layer/geom/text.rs @@ -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)), @@ -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 diff --git a/src/plot/scale/colour.rs b/src/plot/scale/colour.rs index 1408f061e..003cc2517 100644 --- a/src/plot/scale/colour.rs +++ b/src/plot/scale/colour.rs @@ -18,7 +18,10 @@ pub fn color_to_hex(value: &str) -> Result { /// 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" + ) } // ============================================================================= diff --git a/src/plot/scale/mod.rs b/src/plot/scale/mod.rs index fe12bc7e4..3b87fab37 100644 --- a/src/plot/scale/mod.rs +++ b/src/plot/scale/mod.rs @@ -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 diff --git a/src/plot/scale/palettes.rs b/src/plot/scale/palettes.rs index e5faf0d34..b2f129fb9 100644 --- a/src/plot/scale/palettes.rs +++ b/src/plot/scale/palettes.rs @@ -2126,7 +2126,7 @@ pub fn lookup_palette(aesthetic: &str, name: &str) -> Result, 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" diff --git a/src/plot/scale/scale_type/binned.rs b/src/plot/scale/scale_type/binned.rs index d183a817e..e013a8a17 100644 --- a/src/plot/scale/scale_type/binned.rs +++ b/src/plot/scale/scale_type/binned.rs @@ -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( diff --git a/src/plot/scale/scale_type/continuous.rs b/src/plot/scale/scale_type/continuous.rs index 13e4bf322..96a058ac7 100644 --- a/src/plot/scale/scale_type/continuous.rs +++ b/src/plot/scale/scale_type/continuous.rs @@ -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( diff --git a/src/plot/scale/scale_type/discrete.rs b/src/plot/scale/scale_type/discrete.rs index 14d3968af..eda4500ea 100644 --- a/src/plot/scale/scale_type/discrete.rs +++ b/src/plot/scale/scale_type/discrete.rs @@ -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( diff --git a/src/plot/scale/scale_type/mod.rs b/src/plot/scale/scale_type/mod.rs index facbf9509..55f1d614b 100644 --- a/src/plot/scale/scale_type/mod.rs +++ b/src/plot/scale/scale_type/mod.rs @@ -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() diff --git a/src/plot/scale/scale_type/ordinal.rs b/src/plot/scale/scale_type/ordinal.rs index b573b8379..58123d17f 100644 --- a/src/plot/scale/scale_type/ordinal.rs +++ b/src/plot/scale/scale_type/ordinal.rs @@ -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( diff --git a/src/validate.rs b/src/validate.rs index a9aefcb4b..35ad5bf18 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -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') diff --git a/src/writer/hephaestus/compose.rs b/src/writer/hephaestus/compose.rs index 137e50a02..65512b805 100644 --- a/src/writer/hephaestus/compose.rs +++ b/src/writer/hephaestus/compose.rs @@ -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 diff --git a/src/writer/hephaestus/geom/text.rs b/src/writer/hephaestus/geom/text.rs index acb61aa07..603011ee6 100644 --- a/src/writer/hephaestus/geom/text.rs +++ b/src/writer/hephaestus/geom/text.rs @@ -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. @@ -89,7 +112,11 @@ 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", @@ -97,7 +124,13 @@ fn material() -> [MaterialSpec; 8] { 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", @@ -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) { diff --git a/src/writer/hephaestus/mod.rs b/src/writer/hephaestus/mod.rs index 32720e656..0cc0e31cd 100644 --- a/src/writer/hephaestus/mod.rs +++ b/src/writer/hephaestus/mod.rs @@ -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 @@ -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`