use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::Arc;
use crate::brush::Brush;
use crate::geometry::{Affine, Point, Rect};
use crate::path::FillRule;
use crate::plot::theme::HAlign;
use crate::plot::value::Value;
use crate::primitives::{rect as rect_path, rounded_rect};
use crate::scene::SceneBuilder;
use crate::stroke::{Cap, Join, Stroke};
use crate::style_vocab::ThemeColor;
use crate::text::rich::{
draw_rich_text, pt as rich_pt, HAnchor, RichAnchor, RichKey, RichShapeCache, RichTextRun,
RichTextStyleSheet, RichTextWidth, StyleDelta as RichStyleDelta, VAnchor,
};
use crate::text::{draw_text, TextRun, TextStyle};
use super::resolve::{
band_width_at, override_alpha, pt_to_px, resolve_angle_channel, resolve_bool_channel_or,
resolve_color_channel, resolve_color_channel_or_theme, resolve_number_channel,
resolve_number_channel_or, resolve_pick_id, resolve_position,
};
use super::state::{
finalize_state, require_data_column, require_x_and_siblings, GeomState, KeysStrategy,
};
use super::{BuildableGeom, Channel, ExpectedOutput, Geom, GeomBuilder, GeomContext};
fn default_fill() -> crate::color::Color {
crate::color::Color::new([0.0, 0.0, 0.0, 1.0])
}
const CHANNELS: &[(&str, ExpectedOutput)] = &[
("x", ExpectedOutput::Numbers),
("y", ExpectedOutput::Numbers),
("x_offset", ExpectedOutput::Numbers),
("y_offset", ExpectedOutput::Numbers),
("x_band", ExpectedOutput::Numbers),
("y_band", ExpectedOutput::Numbers),
("text", ExpectedOutput::Strings),
("size", ExpectedOutput::Numbers),
("weight", ExpectedOutput::Numbers),
("italic", ExpectedOutput::Any),
("family", ExpectedOutput::Strings),
("letter_spacing", ExpectedOutput::Numbers),
("underline", ExpectedOutput::Any),
("strikethrough", ExpectedOutput::Any),
("markdown", ExpectedOutput::Any),
("text_stroke", ExpectedOutput::Colors),
("text_linewidth", ExpectedOutput::Numbers),
("anchor_x", ExpectedOutput::Numbers),
("anchor_y", ExpectedOutput::Numbers),
("fill", ExpectedOutput::Colors),
("fill_opacity", ExpectedOutput::Numbers),
("width", ExpectedOutput::Numbers),
("width_band", ExpectedOutput::Numbers),
("bg_fill", ExpectedOutput::Colors),
("bg_fill_opacity", ExpectedOutput::Numbers),
("bg_stroke", ExpectedOutput::Colors),
("bg_stroke_opacity", ExpectedOutput::Numbers),
("bg_linewidth", ExpectedOutput::Numbers),
("bg_corner_radius", ExpectedOutput::Numbers),
("bg_padding", ExpectedOutput::Numbers),
("angle", ExpectedOutput::Numbers),
("justify_x", ExpectedOutput::Strings),
("pick_id", ExpectedOutput::Numbers),
];
pub struct TextGeom {
pub(crate) state: GeomState,
pub(crate) rich_sheet: Option<Arc<RichTextStyleSheet>>,
pub(crate) rich_cache: RichShapeCache,
pub(crate) rich_outline_sheets: RefCell<HashMap<(usize, u128, u64), Arc<RichTextStyleSheet>>>,
}
crate::impl_geom_inherents!(TextGeom);
impl TextGeom {
pub fn with_rich_sheet(mut self, sheet: Arc<RichTextStyleSheet>) -> Self {
self.rich_sheet = Some(sheet);
self
}
pub fn set_rich_sheet(&mut self, sheet: Arc<RichTextStyleSheet>) {
self.rich_sheet = Some(sheet);
}
pub fn clear_rich_cache(&mut self) {
self.rich_cache.clear();
}
pub fn clear_rich_sheet(&mut self) {
self.rich_sheet = None;
}
}
impl BuildableGeom for TextGeom {
fn build_from(builder: GeomBuilder<Self>) -> Self {
let (keys_opt, channels) = builder.into_parts();
let n = require_x_and_siblings(&channels, &["y"], "TextGeom");
require_data_column("text", &channels, "TextGeom");
let state = finalize_state(
keys_opt,
channels,
n,
KeysStrategy::PerRow,
CHANNELS,
"TextGeom",
);
TextGeom {
state,
rich_sheet: None,
rich_cache: RichShapeCache::new(),
rich_outline_sheets: RefCell::new(HashMap::new()),
}
}
}
impl Geom for TextGeom {
fn state(&self) -> &GeomState {
&self.state
}
fn state_mut(&mut self) -> &mut GeomState {
&mut self.state
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn kind(&self) -> Option<&'static str> {
Some("text")
}
fn invalidate_caches(&mut self) {
self.rich_cache.clear();
self.rich_outline_sheets.borrow_mut().clear();
}
fn draw(&self, scene: &mut dyn SceneBuilder, ctx: &GeomContext<'_>) {
let panel = ctx.panel_rect;
let panel_w = panel.x1 - panel.x0;
let panel_h = panel.y1 - panel.y0;
if panel_w <= 0.0 || panel_h <= 0.0 {
return;
}
let n = self.len();
if n == 0 {
return;
}
let x_scale_bound = ctx.scale_for("x");
let y_scale_bound = ctx.scale_for("y");
let x_offset_scale = ctx.scale_for("x_offset");
let y_offset_scale = ctx.scale_for("y_offset");
let x_band_scale = ctx.scale_for("x_band");
let y_band_scale = ctx.scale_for("y_band");
let text_scale = ctx.scale_for("text");
let size_scale = ctx.scale_for("size");
let weight_scale = ctx.scale_for("weight");
let italic_scale = ctx.scale_for("italic");
let family_scale = ctx.scale_for("family");
let letter_spacing_scale = ctx.scale_for("letter_spacing");
let underline_scale = ctx.scale_for("underline");
let strikethrough_scale = ctx.scale_for("strikethrough");
let markdown_scale = ctx.scale_for("markdown");
let text_stroke_scale = ctx.scale_for("text_stroke");
let text_linewidth_scale = ctx.scale_for("text_linewidth");
let anchor_x_scale = ctx.scale_for("anchor_x");
let anchor_y_scale = ctx.scale_for("anchor_y");
let fill_scale = ctx.scale_for("fill");
let fill_opacity_scale = ctx.scale_for("fill_opacity");
let width_scale = ctx.scale_for("width");
let width_band_scale = ctx.scale_for("width_band");
let bg_fill_scale = ctx.scale_for("bg_fill");
let bg_fill_opacity_scale = ctx.scale_for("bg_fill_opacity");
let bg_stroke_scale = ctx.scale_for("bg_stroke");
let bg_stroke_opacity_scale = ctx.scale_for("bg_stroke_opacity");
let bg_linewidth_scale = ctx.scale_for("bg_linewidth");
let bg_corner_radius_scale = ctx.scale_for("bg_corner_radius");
let bg_padding_scale = ctx.scale_for("bg_padding");
let angle_scale = ctx.scale_for("angle");
let justify_x_scale = ctx.scale_for("justify_x");
let pick_id_scale = ctx.scale_for("pick_id");
let channels = &self.state.channels;
let (x_col, x_scale) = match channels.get("x") {
Some(Channel::Data(c)) => (c, x_scale_bound),
Some(Channel::RawData(c)) => (c, None),
_ => return,
};
let (y_col, y_scale) = match channels.get("y") {
Some(Channel::Data(c)) => (c, y_scale_bound),
Some(Channel::RawData(c)) => (c, None),
_ => return,
};
let text_ch = channels.get("text");
let x_offset_ch = channels.get("x_offset");
let y_offset_ch = channels.get("y_offset");
let x_band_ch = channels.get("x_band");
let y_band_ch = channels.get("y_band");
let size_ch = channels.get("size");
let weight_ch = channels.get("weight");
let italic_ch = channels.get("italic");
let family_ch = channels.get("family");
let letter_spacing_ch = channels.get("letter_spacing");
let underline_ch = channels.get("underline");
let strikethrough_ch = channels.get("strikethrough");
let markdown_ch = channels.get("markdown");
let rich_sheet: &Arc<RichTextStyleSheet> =
self.rich_sheet.as_ref().unwrap_or(&ctx.theme.rich_text);
let text_stroke_ch = channels.get("text_stroke");
let text_linewidth_ch = channels.get("text_linewidth");
let anchor_x_ch = channels.get("anchor_x");
let anchor_y_ch = channels.get("anchor_y");
let fill_ch = channels.get("fill");
let fill_opacity_ch = channels.get("fill_opacity");
let width_ch = channels.get("width");
let width_band_ch = channels.get("width_band");
let bg_fill_ch = channels.get("bg_fill");
let bg_fill_opacity_ch = channels.get("bg_fill_opacity");
let bg_stroke_ch = channels.get("bg_stroke");
let bg_stroke_opacity_ch = channels.get("bg_stroke_opacity");
let bg_linewidth_ch = channels.get("bg_linewidth");
let bg_corner_radius_ch = channels.get("bg_corner_radius");
let bg_padding_ch = channels.get("bg_padding");
let angle_ch = channels.get("angle");
let justify_x_ch = channels.get("justify_x");
let pick_id_ch = channels.get("pick_id");
for i in 0..n {
let text = match resolve_str_channel(text_ch, text_scale, i) {
Some(s) if !s.is_empty() => s,
_ => continue, };
let x_band = resolve_number_channel_or(x_band_ch, x_band_scale, i, 0.0);
let y_band = resolve_number_channel_or(y_band_ch, y_band_scale, i, 0.0);
let x_frac = resolve_position(x_col.get(i), x_scale, x_band);
let y_frac = resolve_position(y_col.get(i), y_scale, y_band);
if !x_frac.is_finite() || !y_frac.is_finite() {
continue;
}
let (apx0, apy0) = ctx.projection.project_to_panel_px(panel, &[x_frac, y_frac]);
let mut anchor_px = apx0;
let mut anchor_py = apy0;
if let Some(off) = resolve_number_channel(x_offset_ch, x_offset_scale, i) {
anchor_px += pt_to_px(off, ctx.dpi);
}
if let Some(off) = resolve_number_channel(y_offset_ch, y_offset_scale, i) {
anchor_py -= pt_to_px(off, ctx.dpi);
}
let size_pt =
resolve_number_channel_or(size_ch, size_scale, i, ctx.theme.geom.text.size_pt);
if !size_pt.is_finite() || size_pt <= 0.0 {
continue;
}
let weight = resolve_number_channel(weight_ch, weight_scale, i)
.map(|w| (w.round() as i64).clamp(1, 1000) as u16)
.unwrap_or(ctx.theme.geom.text.weight);
let italic = resolve_bool_or_italic_string(italic_ch, italic_scale, i);
let family = resolve_str_channel(family_ch, family_scale, i);
let letter_spacing_pt = resolve_number_channel_or(
letter_spacing_ch,
letter_spacing_scale,
i,
ctx.theme.geom.text.letter_spacing_pt,
);
let underline = resolve_bool_channel_or(
underline_ch,
underline_scale,
i,
ctx.theme.geom.text.underline,
);
let strikethrough = resolve_bool_channel_or(
strikethrough_ch,
strikethrough_scale,
i,
ctx.theme.geom.text.strikethrough,
);
let mut style = TextStyle::new(size_pt as f32)
.weight(weight)
.italic(italic)
.letter_spacing_pt(letter_spacing_pt as f32)
.underline(underline)
.strikethrough(strikethrough);
if let Some(fam) = family {
style = style.family(fam);
}
let markdown = resolve_bool_channel_or(
markdown_ch,
markdown_scale,
i,
ctx.theme.geom.text.markdown,
);
if markdown {
let fill_color = override_alpha(
resolve_color_channel_or_theme(
fill_ch,
fill_scale,
i,
ctx.theme.geom.text.fill.as_ref(),
&ctx.theme.palette,
),
resolve_number_channel(fill_opacity_ch, fill_opacity_scale, i),
)
.unwrap_or_else(default_fill);
let row_stroke = resolve_color_channel_or_theme(
text_stroke_ch,
text_stroke_scale,
i,
ctx.theme.geom.text.text_stroke.as_ref(),
&ctx.theme.palette,
);
let row_sheet = match row_stroke {
None => Arc::clone(rich_sheet),
Some(c) => {
let width_pt = resolve_number_channel_or(
text_linewidth_ch,
text_linewidth_scale,
i,
ctx.theme.geom.text.text_linewidth_pt,
);
let [r, g, b, a] = c.components;
let color_bits = (r.to_bits() as u128) << 96
| (g.to_bits() as u128) << 64
| (b.to_bits() as u128) << 32
| a.to_bits() as u128;
let key = (
Arc::as_ptr(rich_sheet) as usize,
color_bits,
width_pt.to_bits(),
);
let mut sheets = self.rich_outline_sheets.borrow_mut();
Arc::clone(sheets.entry(key).or_insert_with(|| {
let mut s = (**rich_sheet).clone();
let base = s.get("base").cloned().unwrap_or_default();
s.set(
"base",
RichStyleDelta {
text_stroke: Some(ThemeColor::Fixed(c)),
text_stroke_width: Some(rich_pt(width_pt)),
..base
},
);
Arc::new(s)
}))
}
};
let x_raw = x_col.get(i);
let x_band_width_px = band_width_at(x_scale, &x_raw) * panel_w;
let width_pt = resolve_number_channel_or(width_ch, width_scale, i, 0.0);
let width_band_frac =
resolve_number_channel_or(width_band_ch, width_band_scale, i, 0.0);
let wrap_width_px = pt_to_px(width_pt, ctx.dpi) + width_band_frac * x_band_width_px;
let justify_x = resolve_justify_channel(justify_x_ch, justify_x_scale, i);
let wraps = wrap_width_px > 0.0 && wrap_width_px.is_finite();
let align = justify_x;
let width_spec = if wraps {
RichTextWidth::Fixed(wrap_width_px as f32)
} else {
RichTextWidth::Natural
};
let key = RichKey::new(
&text,
&style,
fill_color,
&row_sheet,
&ctx.theme.palette,
ctx.dpi,
width_spec,
align,
);
let rich = self.rich_cache.get_or_shape(key, || {
let run = RichTextRun::new(
&text,
&style,
fill_color,
&row_sheet,
&ctx.theme.palette,
ctx.dpi,
);
if wraps {
run.set_max_width(wrap_width_px as f32, align);
}
run
});
let (text_w, text_h) = if wraps {
(rich.content_width(), rich.current_height())
} else {
(rich.natural_width(), rich.natural_height())
};
let anchor_x = resolve_number_channel_or(
anchor_x_ch,
anchor_x_scale,
i,
ctx.theme.geom.text.anchor_x,
);
let anchor_y = resolve_number_channel_or(
anchor_y_ch,
anchor_y_scale,
i,
ctx.theme.geom.text.anchor_y,
);
let pick = resolve_pick_id(pick_id_ch, pick_id_scale, i);
let bg_fill = override_alpha(
resolve_color_channel(bg_fill_ch, bg_fill_scale, i),
resolve_number_channel(bg_fill_opacity_ch, bg_fill_opacity_scale, i),
);
let bg_stroke = override_alpha(
resolve_color_channel(bg_stroke_ch, bg_stroke_scale, i),
resolve_number_channel(bg_stroke_opacity_ch, bg_stroke_opacity_scale, i),
);
let has_bg = bg_fill.is_some() || bg_stroke.is_some();
let (draw_x, draw_y, bg_rect_opt) = if has_bg {
let padding_pt =
resolve_number_channel_or(bg_padding_ch, bg_padding_scale, i, 0.0);
let padding_px = pt_to_px(padding_pt, ctx.dpi);
let bg_w = text_w + 2.0 * padding_px;
let bg_h = text_h + 2.0 * padding_px;
let bg_left = anchor_px - anchor_x * bg_w;
let bg_top = anchor_py - anchor_y * bg_h;
let dx = bg_left + padding_px;
let dy = bg_top + padding_px;
let bg_rect = Rect::new(bg_left, bg_top, bg_left + bg_w, bg_top + bg_h);
(dx, dy, Some(bg_rect))
} else {
let dx = anchor_px - anchor_x * text_w;
let dy = anchor_py - anchor_y * text_h;
(dx, dy, None)
};
let angle = resolve_angle_channel(angle_ch, angle_scale, i);
let xform = if angle == 0.0 {
Affine::IDENTITY
} else {
Affine::rotate_about(-angle, Point::new(anchor_px, anchor_py))
};
if let Some(bg_rect) = bg_rect_opt {
if bg_rect.is_finite() && bg_rect.width() > 0.0 && bg_rect.height() > 0.0 {
let bg_corner_radius_pt = resolve_number_channel_or(
bg_corner_radius_ch,
bg_corner_radius_scale,
i,
0.0,
);
let bg_corner_radius_px = pt_to_px(bg_corner_radius_pt, ctx.dpi).max(0.0);
let bg_path = if bg_corner_radius_px > 0.0 {
rounded_rect(bg_rect, bg_corner_radius_px)
} else {
rect_path(bg_rect)
};
if let Some(fc) = bg_fill {
scene.fill(
FillRule::NonZero,
xform,
&Brush::Solid(fc),
None,
&bg_path,
pick,
);
}
if let Some(sc) = bg_stroke {
let lw_pt = resolve_number_channel_or(
bg_linewidth_ch,
bg_linewidth_scale,
i,
ctx.theme.geom.text.bg_linewidth_pt,
);
let lw_px = pt_to_px(lw_pt, ctx.dpi);
if lw_px.is_finite() && lw_px > 0.0 {
let stroke_spec = Stroke::new(lw_px)
.with_caps(Cap::Butt)
.with_join(Join::Miter);
scene.stroke(
&stroke_spec,
xform,
&Brush::Solid(sc),
None,
&bg_path,
pick,
);
}
}
}
}
draw_rich_text(
scene,
&rich,
draw_x,
draw_y,
RichAnchor {
h: HAnchor::Left,
v: VAnchor::Top,
},
xform,
pick,
);
continue;
}
let run = TextRun::new(&text, &style, ctx.dpi);
let x_raw = x_col.get(i);
let x_band_width_px = band_width_at(x_scale, &x_raw) * panel_w;
let width_pt = resolve_number_channel_or(width_ch, width_scale, i, 0.0);
let width_band_frac =
resolve_number_channel_or(width_band_ch, width_band_scale, i, 0.0);
let wrap_width_px = pt_to_px(width_pt, ctx.dpi) + width_band_frac * x_band_width_px;
let justify_x = resolve_justify_channel(justify_x_ch, justify_x_scale, i);
let (text_w, text_h) = if wrap_width_px > 0.0 && wrap_width_px.is_finite() {
run.set_max_width(wrap_width_px as f32, justify_x);
(run.content_width(), run.current_height())
} else {
(run.natural_width(), run.natural_height())
};
let anchor_x = resolve_number_channel_or(
anchor_x_ch,
anchor_x_scale,
i,
ctx.theme.geom.text.anchor_x,
);
let anchor_y = resolve_number_channel_or(
anchor_y_ch,
anchor_y_scale,
i,
ctx.theme.geom.text.anchor_y,
);
let fill_color = override_alpha(
resolve_color_channel_or_theme(
fill_ch,
fill_scale,
i,
ctx.theme.geom.text.fill.as_ref(),
&ctx.theme.palette,
),
resolve_number_channel(fill_opacity_ch, fill_opacity_scale, i),
)
.unwrap_or_else(default_fill);
let text_stroke_color = resolve_color_channel_or_theme(
text_stroke_ch,
text_stroke_scale,
i,
ctx.theme.geom.text.text_stroke.as_ref(),
&ctx.theme.palette,
);
let text_linewidth_pt = resolve_number_channel_or(
text_linewidth_ch,
text_linewidth_scale,
i,
ctx.theme.geom.text.text_linewidth_pt,
);
let pick = resolve_pick_id(pick_id_ch, pick_id_scale, i);
let bg_fill = override_alpha(
resolve_color_channel(bg_fill_ch, bg_fill_scale, i),
resolve_number_channel(bg_fill_opacity_ch, bg_fill_opacity_scale, i),
);
let bg_stroke = override_alpha(
resolve_color_channel(bg_stroke_ch, bg_stroke_scale, i),
resolve_number_channel(bg_stroke_opacity_ch, bg_stroke_opacity_scale, i),
);
let has_bg = bg_fill.is_some() || bg_stroke.is_some();
let (draw_x, draw_y, bg_rect_opt) = if has_bg {
let padding_pt = resolve_number_channel_or(bg_padding_ch, bg_padding_scale, i, 0.0);
let padding_px = pt_to_px(padding_pt, ctx.dpi);
let descender_px = run.last_line_descender();
let top_pad_eff = padding_px.max(descender_px);
let bottom_pad_eff = (padding_px - descender_px).max(0.0);
let bg_w = text_w + 2.0 * padding_px;
let bg_h = text_h + top_pad_eff + bottom_pad_eff;
let bg_left = anchor_px - anchor_x * bg_w;
let bg_top = anchor_py - anchor_y * bg_h;
let dx = bg_left + padding_px;
let dy = bg_top + top_pad_eff;
let bg_rect = Rect::new(bg_left, bg_top, bg_left + bg_w, bg_top + bg_h);
(dx, dy, Some(bg_rect))
} else {
let dx = anchor_px - anchor_x * text_w;
let dy = anchor_py - anchor_y * text_h;
(dx, dy, None)
};
let angle = resolve_angle_channel(angle_ch, angle_scale, i);
let xform = if angle == 0.0 {
Affine::IDENTITY
} else {
Affine::rotate_about(-angle, Point::new(anchor_px, anchor_py))
};
if let Some(bg_rect) = bg_rect_opt {
if bg_rect.is_finite() && bg_rect.width() > 0.0 && bg_rect.height() > 0.0 {
let bg_corner_radius_pt = resolve_number_channel_or(
bg_corner_radius_ch,
bg_corner_radius_scale,
i,
0.0,
);
let bg_corner_radius_px = pt_to_px(bg_corner_radius_pt, ctx.dpi).max(0.0);
let bg_path = if bg_corner_radius_px > 0.0 {
rounded_rect(bg_rect, bg_corner_radius_px)
} else {
rect_path(bg_rect)
};
if let Some(fc) = bg_fill {
scene.fill(
FillRule::NonZero,
xform,
&Brush::Solid(fc),
None,
&bg_path,
pick,
);
}
if let Some(sc) = bg_stroke {
let lw_pt = resolve_number_channel_or(
bg_linewidth_ch,
bg_linewidth_scale,
i,
ctx.theme.geom.text.bg_linewidth_pt,
);
let lw_px = pt_to_px(lw_pt, ctx.dpi);
if lw_px.is_finite() && lw_px > 0.0 {
let stroke_spec = Stroke::new(lw_px)
.with_caps(Cap::Butt)
.with_join(Join::Miter);
scene.stroke(
&stroke_spec,
xform,
&Brush::Solid(sc),
None,
&bg_path,
pick,
);
}
}
}
}
if let Some(stroke_color) = text_stroke_color {
let stroke_width_px = pt_to_px(text_linewidth_pt, ctx.dpi);
if stroke_width_px > 0.0 {
let stroke = crate::stroke::Stroke::new(stroke_width_px);
crate::text::draw_text_outline(
scene,
&run,
draw_x,
draw_y,
&Brush::Solid(stroke_color),
&stroke,
xform,
crate::pick::PickId::Skip,
);
}
}
draw_text(
scene,
&run,
draw_x,
draw_y,
&Brush::Solid(fill_color),
xform,
pick,
);
}
}
}
fn resolve_str_channel(
channel: Option<&Channel>,
scale: Option<&crate::plot::scale::Scale>,
i: usize,
) -> Option<String> {
let (raw, bypass) = match channel? {
Channel::Constant(v) => (v.clone(), false),
Channel::Data(col) => (col.get(i), false),
Channel::RawConstant(v) => (v.clone(), true),
Channel::RawData(col) => (col.get(i), true),
};
let mapped = match (bypass, scale) {
(true, _) | (false, None) => raw,
(false, Some(s)) => s.map(&raw),
};
mapped.as_str().map(str::to_owned)
}
fn resolve_justify_channel(
channel: Option<&Channel>,
scale: Option<&crate::plot::scale::Scale>,
i: usize,
) -> HAlign {
let s = match resolve_str_channel(channel, scale, i) {
Some(s) => s,
None => return HAlign::Start,
};
match s.as_str() {
"start" => HAlign::Start,
"center" | "centre" | "middle" => HAlign::Center,
"end" => HAlign::End,
"justify" | "justified" => HAlign::Justify,
_ => HAlign::Start,
}
}
fn resolve_bool_or_italic_string(
channel: Option<&Channel>,
scale: Option<&crate::plot::scale::Scale>,
i: usize,
) -> bool {
let (raw, bypass) = match channel {
None => return false,
Some(Channel::Constant(v)) => (v.clone(), false),
Some(Channel::Data(col)) => (col.get(i), false),
Some(Channel::RawConstant(v)) => (v.clone(), true),
Some(Channel::RawData(col)) => (col.get(i), true),
};
let mapped = match (bypass, scale) {
(true, _) | (false, None) => raw,
(false, Some(s)) => s.map(&raw),
};
match mapped {
Value::Bool(b) => b,
Value::String(s) => matches!(&*s, "italic" | "oblique"),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::color::Color;
use crate::geometry::Rect;
use crate::plot::geom::DirectScaleResolver;
use crate::plot::scale;
use crate::scene::recording::{Op, RecordingScene};
fn shapes() -> crate::shape::ShapeRegistry {
crate::shape::ShapeRegistry::with_builtins()
}
fn ctx<'a>(
panel: Rect,
registry: &'a crate::shape::ShapeRegistry,
scales: &'a DirectScaleResolver<'a>,
) -> GeomContext<'a> {
GeomContext::new(panel, 96.0, registry, scales)
}
fn red() -> Color {
Color::new([1.0, 0.0, 0.0, 1.0])
}
#[test]
#[should_panic(expected = "missing required channel \"text\"")]
fn missing_text_panics() {
TextGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.build();
}
#[test]
#[should_panic(expected = "missing required channel \"x\"")]
fn missing_x_panics() {
TextGeom::builder()
.set("y", vec![0.5_f64])
.set("text", vec!["hi"])
.build();
}
#[test]
#[should_panic(expected = "does not match")]
fn length_mismatch_panics() {
TextGeom::builder()
.set("x", vec![0.5_f64, 0.7])
.set("y", vec![0.5_f64])
.set("text", vec!["a", "b"])
.build();
}
fn glyph_count(scene: &RecordingScene) -> usize {
scene
.ops
.iter()
.filter(|op| matches!(op, Op::DrawGlyphs(_)))
.count()
}
#[test]
fn empty_text_skips_row() {
let g = TextGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("text", vec![""])
.set("fill", red())
.build();
let shapes = shapes();
let scales = DirectScaleResolver::new();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 200.0, 100.0), &shapes, &scales),
);
assert_eq!(glyph_count(&scene), 0);
}
#[test]
fn renders_one_label_per_row() {
let g = TextGeom::builder()
.set("x", vec![0.2_f64, 0.5, 0.8])
.set("y", vec![0.5_f64, 0.5, 0.5])
.set("text", vec!["alpha", "beta", "gamma"])
.set("fill", red())
.build();
let shapes = shapes();
let scales = DirectScaleResolver::new();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 400.0, 100.0), &shapes, &scales),
);
assert!(glyph_count(&scene) >= 3, "got {}", glyph_count(&scene));
}
#[test]
fn nonfinite_position_skips_row() {
let g = TextGeom::builder()
.set("x", vec![0.5_f64, f64::NAN])
.set("y", vec![0.5_f64, 0.5])
.set("text", vec!["a", "b"])
.set("fill", red())
.build();
let shapes = shapes();
let scales = DirectScaleResolver::new();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 200.0, 100.0), &shapes, &scales),
);
assert!(glyph_count(&scene) >= 1);
}
#[test]
fn default_fill_is_black_when_unbound() {
let g = TextGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("text", vec!["hello"])
.build();
let shapes = shapes();
let scales = DirectScaleResolver::new();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 200.0, 100.0), &shapes, &scales),
);
assert!(glyph_count(&scene) >= 1);
}
#[test]
fn pick_id_channel_passes_through_per_row() {
let g = TextGeom::builder()
.set("x", vec![0.2_f64, 0.5, 0.8])
.set("y", vec![0.5_f64, 0.5, 0.5])
.set("text", vec!["A", "B", "C"])
.set("fill", red())
.set("pick_id", vec![41_i64, 42, 43])
.build();
let shapes = shapes();
let scales = DirectScaleResolver::new();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 400.0, 100.0), &shapes, &scales),
);
let picks: std::collections::HashSet<u32> = scene
.ops
.iter()
.filter_map(|op| match op {
Op::DrawGlyphs(run) => match run.pick_id {
crate::pick::PickId::Id(n) => Some(n),
_ => None,
},
_ => None,
})
.collect();
assert_eq!(picks, [41u32, 42, 43].into_iter().collect());
}
#[test]
fn anchor_centre_is_default() {
let g = TextGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("text", vec!["centered"])
.set("fill", red())
.build();
let shapes = shapes();
let scales = DirectScaleResolver::new();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales),
);
assert!(glyph_count(&scene) >= 1);
}
#[test]
fn size_scaled_by_dpi() {
let g = TextGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("text", vec!["x"])
.set("size", vec![12.0_f64])
.set("fill", red())
.build();
let shapes = shapes();
let scales = DirectScaleResolver::new();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 200.0, 100.0), &shapes, &scales),
);
for op in &scene.ops {
if let Op::DrawGlyphs(run) = op {
assert!(
(run.font_size as f64 - 12.0 * 96.0 / 72.0).abs() < 1e-3,
"font_size = {}, expected ~16.0",
run.font_size
);
return;
}
}
panic!("no glyph run emitted");
}
#[test]
fn declared_channels_alphabetical() {
let g = TextGeom::builder()
.set("x", vec![0.0_f64])
.set("y", vec![0.0_f64])
.set("text", vec!["x"])
.set("fill", red())
.build();
let names: Vec<&str> = g.declared_channels().iter().map(|d| d.name).collect();
let mut sorted = names.clone();
sorted.sort();
assert_eq!(names, sorted);
assert!(names.contains(&"text"));
assert!(!names.contains(&"x2"));
assert!(!names.contains(&"radius"));
}
#[test]
fn italic_via_string() {
let g = TextGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("text", vec!["x"])
.set("italic", Value::String(Arc::from("italic")))
.set("fill", red())
.build();
let shapes = shapes();
let scales = DirectScaleResolver::new();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 200.0, 100.0), &shapes, &scales),
);
assert!(glyph_count(&scene) >= 1);
}
fn fill_count(scene: &RecordingScene) -> usize {
scene
.ops
.iter()
.filter(|op| matches!(op, Op::Fill { .. }))
.count()
}
fn stroke_count(scene: &RecordingScene) -> usize {
scene
.ops
.iter()
.filter(|op| matches!(op, Op::Stroke { .. }))
.count()
}
#[test]
fn bg_fill_emits_rect_before_glyphs() {
let g = TextGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("text", vec!["hi"])
.set("fill", red())
.set("bg_fill", Color::new([0.9, 0.9, 0.7, 1.0]))
.build();
let shapes = shapes();
let scales = DirectScaleResolver::new();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 200.0, 100.0), &shapes, &scales),
);
assert_eq!(fill_count(&scene), 1);
assert!(glyph_count(&scene) >= 1);
let fill_idx = scene
.ops
.iter()
.position(|op| matches!(op, Op::Fill { .. }))
.unwrap();
let glyph_idx = scene
.ops
.iter()
.position(|op| matches!(op, Op::DrawGlyphs(_)))
.unwrap();
assert!(fill_idx < glyph_idx, "bg fill should precede glyphs");
}
#[test]
fn bg_stroke_only_emits_stroke_no_fill() {
let g = TextGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("text", vec!["hi"])
.set("fill", red())
.set("bg_stroke", Color::new([0.2, 0.2, 0.2, 1.0]))
.build();
let shapes = shapes();
let scales = DirectScaleResolver::new();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 200.0, 100.0), &shapes, &scales),
);
assert_eq!(fill_count(&scene), 0);
assert_eq!(stroke_count(&scene), 1);
}
#[test]
fn bg_unbound_emits_no_rect() {
let g = TextGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("text", vec!["hi"])
.set("fill", red())
.build();
let shapes = shapes();
let scales = DirectScaleResolver::new();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 200.0, 100.0), &shapes, &scales),
);
assert_eq!(fill_count(&scene), 0);
assert_eq!(stroke_count(&scene), 0);
assert!(glyph_count(&scene) >= 1);
}
#[test]
fn bg_padding_extends_rect() {
let g_low = TextGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("text", vec!["hi"])
.set("fill", red())
.set("bg_fill", red())
.set("bg_padding", 6.0_f64) .build();
let g_high = TextGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("text", vec!["hi"])
.set("fill", red())
.set("bg_fill", red())
.set("bg_padding", 15.0_f64) .build();
let shapes = shapes();
let scales = DirectScaleResolver::new();
let mut s_low = RecordingScene::default();
let mut s_high = RecordingScene::default();
let c = ctx(Rect::new(0.0, 0.0, 200.0, 100.0), &shapes, &scales);
g_low.draw(&mut s_low, &c);
g_high.draw(&mut s_high, &c);
let bb_low = fill_bbox(&s_low).expect("fill low");
let bb_high = fill_bbox(&s_high).expect("fill high");
let expected_delta_px = 2.0 * (15.0 - 6.0) * 96.0 / 72.0; assert!(
(bb_high.width() - bb_low.width() - expected_delta_px).abs() < 0.5,
"width delta {} (expected {})",
bb_high.width() - bb_low.width(),
expected_delta_px
);
assert!(
(bb_high.height() - bb_low.height() - expected_delta_px).abs() < 0.5,
"height delta {} (expected {})",
bb_high.height() - bb_low.height(),
expected_delta_px
);
}
#[test]
fn bg_rebalance_reserves_descender_at_zero_padding() {
let g = TextGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("text", vec!["men"]) .set("fill", red())
.set("bg_fill", red())
.build();
let shapes = shapes();
let scales = DirectScaleResolver::new();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 200.0, 100.0), &shapes, &scales),
);
let bb = fill_bbox(&scene).expect("fill");
let style = TextStyle::new(12.0).weight(400);
let probe = TextRun::new("men", &style, 96.0);
let text_h = probe.natural_height();
let descender = probe.last_line_descender();
let expected = text_h + descender;
assert!(
(bb.height() - expected).abs() < 0.5,
"bg height {} (expected text_h={} + descender={} = {})",
bb.height(),
text_h,
descender,
expected
);
}
#[test]
fn bg_corner_radius_uses_rounded_path() {
let g = TextGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("text", vec!["hi"])
.set("fill", red())
.set("bg_fill", Color::new([0.9, 0.9, 0.7, 1.0]))
.set("bg_corner_radius", 4.0_f64)
.build();
let shapes = shapes();
let scales = DirectScaleResolver::new();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 200.0, 100.0), &shapes, &scales),
);
for op in &scene.ops {
if let Op::Fill { path, .. } = op {
let has_curves = path.elements().iter().any(|el| {
matches!(
el,
crate::path::PathEl::CurveTo(_, _, _) | crate::path::PathEl::QuadTo(_, _)
)
});
assert!(has_curves, "rounded rect should have curves");
return;
}
}
panic!("no fill emitted");
}
#[test]
fn bg_shares_pick_id_with_glyphs() {
let g = TextGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("text", vec!["hi"])
.set("fill", red())
.set("bg_fill", red())
.set("pick_id", 99_i64)
.build();
let shapes = shapes();
let scales = DirectScaleResolver::new();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 200.0, 100.0), &shapes, &scales),
);
let bg_pick = scene.ops.iter().find_map(|op| match op {
Op::Fill {
pick_id: crate::pick::PickId::Id(n),
..
} => Some(*n),
_ => None,
});
let glyph_pick = scene.ops.iter().find_map(|op| match op {
Op::DrawGlyphs(run) => match run.pick_id {
crate::pick::PickId::Id(n) => Some(n),
_ => None,
},
_ => None,
});
assert_eq!(bg_pick, Some(99));
assert_eq!(glyph_pick, Some(99));
}
#[test]
fn width_pt_constrains_layout_height() {
let long = "Lorem ipsum dolor sit amet, consectetur adipiscing elit";
let g_unwrapped = TextGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("text", vec![long])
.set("fill", red())
.set("bg_fill", red())
.build();
let g_wrapped = TextGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("text", vec![long])
.set("fill", red())
.set("bg_fill", red())
.set("width", 100.0_f64) .build();
let shapes = shapes();
let scales = DirectScaleResolver::new();
let mut s0 = RecordingScene::default();
let mut s1 = RecordingScene::default();
let c = ctx(Rect::new(0.0, 0.0, 1000.0, 600.0), &shapes, &scales);
g_unwrapped.draw(&mut s0, &c);
g_wrapped.draw(&mut s1, &c);
let bb0 = fill_bbox(&s0).expect("fill0");
let bb1 = fill_bbox(&s1).expect("fill1");
assert!(
bb1.height() > bb0.height() + 5.0,
"wrapped should be taller: bb0.h={}, bb1.h={}",
bb0.height(),
bb1.height()
);
assert!(
bb1.width() < bb0.width(),
"wrapped should be narrower than unwrapped: bb0.w={}, bb1.w={}",
bb0.width(),
bb1.width()
);
}
#[test]
fn width_band_wraps_within_discrete_band() {
let x_scale = scale::discrete(
["A", "B", "C", "D"]
.into_iter()
.map(|s| Value::String(Arc::from(s))),
);
let resolver = DirectScaleResolver::new().with("x", &x_scale);
let g = TextGeom::builder()
.set("x", vec!["B"])
.set("y", vec![0.5_f64])
.set("text", vec!["wrapped within category band"])
.set("width_band", 1.0_f64)
.set("fill", red())
.set("bg_fill", red())
.build();
let shapes = shapes();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 200.0, 100.0), &shapes, &resolver),
);
let bb = fill_bbox(&scene).expect("fill");
assert!(bb.width() > 0.0, "width = {}", bb.width());
assert!(
bb.width() < 150.0,
"wrapped should be narrower than natural: width = {}",
bb.width()
);
}
#[test]
fn width_pt_and_band_sum_triggers_wrap() {
let x_scale = scale::discrete(["A", "B"].into_iter().map(|s| Value::String(Arc::from(s))));
let resolver = DirectScaleResolver::new().with("x", &x_scale);
let g = TextGeom::builder()
.set("x", vec!["A"])
.set("y", vec![0.5_f64])
.set("text", vec!["long-running label text"])
.set("width", -9.0_f64)
.set("width_band", 1.0_f64)
.set("fill", red())
.set("bg_fill", red())
.build();
let shapes = shapes();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &resolver),
);
let bb = fill_bbox(&scene).expect("fill");
assert!(bb.width() > 0.0, "width = {}", bb.width());
assert!(
bb.width() < 80.0,
"wrap should fire: width = {}",
bb.width()
);
}
#[test]
fn bg_matches_content_width_not_wrap_constraint() {
let g = TextGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("text", vec!["short"])
.set("width", 500.0_f64) .set("fill", red())
.set("bg_fill", red())
.build();
let shapes = shapes();
let scales = DirectScaleResolver::new();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 1000.0, 200.0), &shapes, &scales),
);
let bb = fill_bbox(&scene).expect("fill");
let wrap_px = 500.0 * 96.0 / 72.0;
assert!(
bb.width() < wrap_px * 0.5,
"bg width {} should be much less than wrap constraint {}",
bb.width(),
wrap_px
);
}
fn glyph_extent(scene: &RecordingScene) -> Option<(f32, f32)> {
let mut lo = f32::INFINITY;
let mut hi = f32::NEG_INFINITY;
for op in &scene.ops {
if let Op::DrawGlyphs(run) = op {
for g in &run.glyphs {
lo = lo.min(g.x);
hi = hi.max(g.x);
}
}
}
(lo.is_finite() && hi.is_finite()).then_some((lo, hi))
}
fn count_fills(scene: &RecordingScene) -> usize {
scene
.ops
.iter()
.filter(|op| matches!(op, Op::Fill { .. }))
.count()
}
fn draw_text_geom_to_scene(g: TextGeom) -> RecordingScene {
let shapes = shapes();
let scales = DirectScaleResolver::new();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 400.0, 200.0), &shapes, &scales),
);
scene
}
#[test]
fn text_stroke_emits_outline_pass() {
let make_plain = || {
let g = TextGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("text", vec!["hello"])
.set("fill", red())
.set("text_linewidth", 1.5_f64)
.build();
draw_text_geom_to_scene(g)
};
let make_outlined = || {
let g = TextGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("text", vec!["hello"])
.set("fill", red())
.set("text_linewidth", 1.5_f64)
.set("text_stroke", Color::new([0.0, 0.0, 1.0, 1.0]))
.build();
draw_text_geom_to_scene(g)
};
let count_stroked = |scene: &RecordingScene| {
scene
.ops
.iter()
.filter(|op| matches!(op, Op::DrawGlyphs(g) if g.style.is_some()))
.count()
};
assert_eq!(count_stroked(&make_plain()), 0);
assert!(count_stroked(&make_outlined()) >= 1);
}
#[test]
fn underline_channel_adds_fill_per_label() {
let make = |underline: bool| {
let g = TextGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("text", vec!["hello"])
.set("fill", red())
.set("underline", underline)
.build();
draw_text_geom_to_scene(g)
};
let base = count_fills(&make(false));
let underlined = count_fills(&make(true));
assert_eq!(underlined, base + 1);
}
#[test]
fn strikethrough_channel_adds_fill_per_label() {
let make = |strike: bool| {
let g = TextGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("text", vec!["hello"])
.set("fill", red())
.set("strikethrough", strike)
.build();
draw_text_geom_to_scene(g)
};
let base = count_fills(&make(false));
let struck = count_fills(&make(true));
assert_eq!(struck, base + 1);
}
#[test]
fn letter_spacing_channel_widens_emitted_glyphs() {
let make = |spacing: f64| {
let g = TextGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("text", vec!["MMMM"])
.set("fill", red())
.set("letter_spacing", spacing)
.build();
let shapes = shapes();
let scales = DirectScaleResolver::new();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 400.0, 200.0), &shapes, &scales),
);
let (lo, hi) = glyph_extent(&scene).expect("glyphs");
(hi - lo) as f64
};
let base = make(0.0);
let loose = make(8.0);
assert!(
loose > base + 5.0,
"letter_spacing=8pt should widen glyph extent: base={base}, loose={loose}"
);
}
fn fill_bbox(scene: &RecordingScene) -> Option<Rect> {
use crate::geometry::Shape as _;
scene.ops.iter().find_map(|op| match op {
Op::Fill { path, .. } => {
let bb = path.bounding_box();
Some(Rect::new(bb.x0, bb.y0, bb.x1, bb.y1))
}
_ => None,
})
}
#[test]
fn x_band_shifts_anchor() {
let x = scale::discrete(["A", "B"].into_iter().map(|s| Value::String(Arc::from(s))));
let resolver = DirectScaleResolver::new().with("x", &x);
let g = TextGeom::builder()
.set("x", vec!["A"])
.set("y", vec![0.5_f64])
.set("text", vec!["L"])
.set("x_band", vec![0.0_f64])
.set("fill", red())
.build();
let shapes = shapes();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 200.0, 100.0), &shapes, &resolver),
);
assert!(glyph_count(&scene) >= 1);
}
#[test]
fn markdown_channel_switches_to_rich_text_path() {
let make = |md: bool| {
TextGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("text", vec!["{.red hi}"])
.set("fill", Color::new([0.0, 0.0, 0.0, 1.0]))
.set("markdown", md)
.build()
};
let s_plain = draw_text_geom_to_scene(make(false));
let s_md = draw_text_geom_to_scene(make(true));
let has_red_glyphs = |scene: &RecordingScene| {
scene.ops.iter().any(|op| match op {
Op::DrawGlyphs(run) => match &run.brush {
Brush::Solid(c) => {
let [r, g, b, _] = c.components;
(r - 1.0).abs() < 1e-3 && g < 0.1 && b < 0.1
}
_ => false,
},
_ => false,
})
};
assert!(
!has_red_glyphs(&s_plain),
"plain path shouldn't produce red glyphs"
);
assert!(
has_red_glyphs(&s_md),
"markdown path should produce red glyphs from {{.red hi}}"
);
}
#[test]
fn markdown_code_block_paints_inside_geom_bg() {
let g = TextGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("text", vec!["```\nlet x = 1;\n```"])
.set("markdown", true)
.set("fill", Color::new([0.0, 0.0, 0.0, 1.0]))
.set("bg_fill", Color::new([1.0, 1.0, 0.5, 1.0])) .build();
let scene = draw_text_geom_to_scene(g);
let fills = count_fills(&scene);
assert!(
fills >= 2,
"expected outer bg fill + code_block fill (≥ 2), got {fills}"
);
}
#[test]
fn markdown_theme_default_toggles_rich_path() {
let g = TextGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("text", vec!["*hi*"])
.set("fill", Color::new([0.0, 0.0, 0.0, 1.0]))
.build();
let shapes = shapes();
let scales = DirectScaleResolver::new();
let ctx_plain = ctx(Rect::new(0.0, 0.0, 400.0, 200.0), &shapes, &scales);
let mut s_plain = RecordingScene::default();
g.draw(&mut s_plain, &ctx_plain);
let mut theme_md = crate::plot::theme::Theme::default();
theme_md.geom.text.markdown = true;
let ctx_md = ctx(Rect::new(0.0, 0.0, 400.0, 200.0), &shapes, &scales).with_theme(&theme_md);
let mut s_md = RecordingScene::default();
g.draw(&mut s_md, &ctx_md);
let count_glyphs = |sc: &RecordingScene| {
sc.ops
.iter()
.map(|op| match op {
Op::DrawGlyphs(r) => r.glyphs.len(),
_ => 0,
})
.sum::<usize>()
};
assert!(
count_glyphs(&s_md) < count_glyphs(&s_plain),
"markdown theme default should strip `*` markers (plain={}, md={})",
count_glyphs(&s_plain),
count_glyphs(&s_md)
);
}
#[test]
fn markdown_rows_shape_once_and_are_reused_next_frame() {
let geom = TextGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("text", vec!["**bold** and *italic*"])
.set("markdown", vec![true])
.build();
let registry = shapes();
let sx = scale::continuous(0.0..=1.0);
let scales = DirectScaleResolver::new().with("x", &sx).with("y", &sx);
let panel = Rect::new(0.0, 0.0, 200.0, 100.0);
let mut scene = RecordingScene::default();
geom.draw(&mut scene, &ctx(panel, ®istry, &scales));
assert_eq!(geom.rich_cache.len(), 1, "the row should be cached");
let ops_after_first = scene.ops.len();
geom.draw(&mut scene, &ctx(panel, ®istry, &scales));
assert_eq!(
geom.rich_cache.len(),
1,
"the second frame must reuse the shaped run, not add another"
);
assert_eq!(
scene.ops.len(),
ops_after_first * 2,
"both frames should emit the same drawing work"
);
}
#[test]
fn invalidate_caches_drops_shaped_markdown() {
let mut geom = TextGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("text", vec!["**bold**"])
.set("markdown", vec![true])
.build();
let registry = shapes();
let sx = scale::continuous(0.0..=1.0);
let scales = DirectScaleResolver::new().with("x", &sx).with("y", &sx);
let mut scene = RecordingScene::default();
geom.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 200.0, 100.0), ®istry, &scales),
);
assert!(!geom.rich_cache.is_empty());
geom.invalidate_caches();
assert!(geom.rich_cache.is_empty());
}
}