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::text::{draw_text, TextRun, TextStyle};
use super::resolve::{
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};
const MAX_ITERS: usize = 4;
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),
("x2", ExpectedOutput::Numbers),
("y2", ExpectedOutput::Numbers),
("x_offset", ExpectedOutput::Numbers),
("y_offset", ExpectedOutput::Numbers),
("x2_offset", ExpectedOutput::Numbers),
("y2_offset", ExpectedOutput::Numbers),
("x_band", ExpectedOutput::Numbers),
("y_band", ExpectedOutput::Numbers),
("x2_band", ExpectedOutput::Numbers),
("y2_band", ExpectedOutput::Numbers),
("text", ExpectedOutput::Strings),
("family", ExpectedOutput::Strings),
("weight", ExpectedOutput::Numbers),
("italic", ExpectedOutput::Any),
("letter_spacing", ExpectedOutput::Numbers),
("underline", ExpectedOutput::Any),
("strikethrough", ExpectedOutput::Any),
("text_stroke", ExpectedOutput::Colors),
("text_linewidth", ExpectedOutput::Numbers),
("min_font_size", ExpectedOutput::Numbers),
("max_font_size", ExpectedOutput::Numbers),
("fill", ExpectedOutput::Colors),
("fill_opacity", 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),
("justify_x", ExpectedOutput::Strings),
("justify_y", ExpectedOutput::Strings),
("angle", ExpectedOutput::Numbers),
("pick_id", ExpectedOutput::Numbers),
];
pub struct TextFitGeom {
pub(crate) state: GeomState,
}
crate::impl_geom_inherents!(TextFitGeom);
impl BuildableGeom for TextFitGeom {
fn build_from(builder: GeomBuilder<Self>) -> Self {
let (keys_opt, channels) = builder.into_parts();
let n = require_x_and_siblings(&channels, &["y", "x2", "y2"], "TextFitGeom");
require_data_column("text", &channels, "TextFitGeom");
let state = finalize_state(
keys_opt,
channels,
n,
KeysStrategy::PerRow,
CHANNELS,
"TextFitGeom",
);
TextFitGeom { state }
}
}
impl Geom for TextFitGeom {
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 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 x2_scale_bound = ctx.scale_for("x2").or(x_scale_bound);
let y2_scale_bound = ctx.scale_for("y2").or(y_scale_bound);
let x_offset_scale = ctx.scale_for("x_offset");
let y_offset_scale = ctx.scale_for("y_offset");
let x2_offset_scale = ctx.scale_for("x2_offset");
let y2_offset_scale = ctx.scale_for("y2_offset");
let x_band_scale = ctx.scale_for("x_band");
let y_band_scale = ctx.scale_for("y_band");
let x2_band_scale = ctx.scale_for("x2_band");
let y2_band_scale = ctx.scale_for("y2_band");
let text_scale = ctx.scale_for("text");
let family_scale = ctx.scale_for("family");
let weight_scale = ctx.scale_for("weight");
let italic_scale = ctx.scale_for("italic");
let letter_spacing_scale = ctx.scale_for("letter_spacing");
let underline_scale = ctx.scale_for("underline");
let strikethrough_scale = ctx.scale_for("strikethrough");
let text_stroke_scale = ctx.scale_for("text_stroke");
let text_linewidth_scale = ctx.scale_for("text_linewidth");
let min_font_scale = ctx.scale_for("min_font_size");
let max_font_scale = ctx.scale_for("max_font_size");
let fill_scale = ctx.scale_for("fill");
let fill_opacity_scale = ctx.scale_for("fill_opacity");
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 justify_x_scale = ctx.scale_for("justify_x");
let justify_y_scale = ctx.scale_for("justify_y");
let angle_scale = ctx.scale_for("angle");
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 (x2_col, x2_scale) = match channels.get("x2") {
Some(Channel::Data(c)) => (c, x2_scale_bound),
Some(Channel::RawData(c)) => (c, None),
_ => return,
};
let (y2_col, y2_scale) = match channels.get("y2") {
Some(Channel::Data(c)) => (c, y2_scale_bound),
Some(Channel::RawData(c)) => (c, None),
_ => return,
};
let text_ch = channels.get("text");
let family_ch = channels.get("family");
let weight_ch = channels.get("weight");
let italic_ch = channels.get("italic");
let letter_spacing_ch = channels.get("letter_spacing");
let underline_ch = channels.get("underline");
let strikethrough_ch = channels.get("strikethrough");
let text_stroke_ch = channels.get("text_stroke");
let text_linewidth_ch = channels.get("text_linewidth");
let min_font_ch = channels.get("min_font_size");
let max_font_ch = channels.get("max_font_size");
let x_offset_ch = channels.get("x_offset");
let y_offset_ch = channels.get("y_offset");
let x2_offset_ch = channels.get("x2_offset");
let y2_offset_ch = channels.get("y2_offset");
let x_band_ch = channels.get("x_band");
let y_band_ch = channels.get("y_band");
let x2_band_ch = channels.get("x2_band");
let y2_band_ch = channels.get("y2_band");
let fill_ch = channels.get("fill");
let fill_opacity_ch = channels.get("fill_opacity");
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 justify_x_ch = channels.get("justify_x");
let justify_y_ch = channels.get("justify_y");
let angle_ch = channels.get("angle");
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 x2_band = resolve_number_channel_or(x2_band_ch, x2_band_scale, i, 0.0);
let y_band = resolve_number_channel_or(y_band_ch, y_band_scale, i, 0.0);
let y2_band = resolve_number_channel_or(y2_band_ch, y2_band_scale, i, 0.0);
let x_frac = resolve_position(x_col.get(i), x_scale, x_band);
let x2_frac = resolve_position(x2_col.get(i), x2_scale, x2_band);
let y_frac = resolve_position(y_col.get(i), y_scale, y_band);
let y2_frac = resolve_position(y2_col.get(i), y2_scale, y2_band);
if !x_frac.is_finite()
|| !x2_frac.is_finite()
|| !y_frac.is_finite()
|| !y2_frac.is_finite()
{
continue;
}
let (px0, py0) = ctx.projection.project_to_panel_px(panel, &[x_frac, y_frac]);
let (px20, py20) = ctx
.projection
.project_to_panel_px(panel, &[x2_frac, y2_frac]);
let mut px = px0;
let mut px2 = px20;
let mut py = py0;
let mut py2 = py20;
if let Some(off) = resolve_number_channel(x_offset_ch, x_offset_scale, i) {
px += pt_to_px(off, ctx.dpi);
}
if let Some(off) = resolve_number_channel(x2_offset_ch, x2_offset_scale, i) {
px2 += pt_to_px(off, ctx.dpi);
}
if let Some(off) = resolve_number_channel(y_offset_ch, y_offset_scale, i) {
py -= pt_to_px(off, ctx.dpi);
}
if let Some(off) = resolve_number_channel(y2_offset_ch, y2_offset_scale, i) {
py2 -= pt_to_px(off, ctx.dpi);
}
let rx0 = px.min(px2);
let rx1 = px.max(px2);
let ry0 = py.min(py2);
let ry1 = py.max(py2);
let rect = Rect::new(rx0, ry0, rx1, ry1);
if !rect.is_finite() || rect.width() <= 0.0 || rect.height() <= 0.0 {
continue;
}
let rect_w = rect.width();
let rect_h = rect.height();
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_fit.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_fit.letter_spacing_pt,
) as f32;
let underline = resolve_bool_channel_or(
underline_ch,
underline_scale,
i,
ctx.theme.geom.text_fit.underline,
);
let strikethrough = resolve_bool_channel_or(
strikethrough_ch,
strikethrough_scale,
i,
ctx.theme.geom.text_fit.strikethrough,
);
let min_pt = resolve_number_channel_or(
min_font_ch,
min_font_scale,
i,
ctx.theme.geom.text_fit.min_font_pt,
)
.max(0.5);
let max_pt = resolve_number_channel_or(
max_font_ch,
max_font_scale,
i,
ctx.theme.geom.text_fit.max_font_pt,
)
.max(min_pt);
let min_pt_f32 = min_pt as f32;
let max_pt_f32 = max_pt as f32;
let justify_x = resolve_justify_x(justify_x_ch, justify_x_scale, i);
let justify_y_frac = resolve_justify_y_frac(justify_y_ch, justify_y_scale, i);
let make_style = |size_pt: f32| {
let mut s = TextStyle::new(size_pt)
.weight(weight)
.italic(italic)
.letter_spacing_pt(letter_spacing_pt)
.underline(underline)
.strikethrough(strikethrough);
if let Some(f) = &family {
s = s.family(f);
}
s
};
let measure = |size_pt: f32| {
let style = make_style(size_pt);
let run = TextRun::new(&text, &style, ctx.dpi);
run.set_max_width(rect_w as f32, justify_x);
let w = run.content_width();
let h = run.current_height();
(run, w, h)
};
let (run_at_max, w_at_max, h_at_max) = measure(max_pt_f32);
let mut best: Option<(TextRun, f64, f64, f32)> =
if w_at_max <= rect_w && h_at_max <= rect_h {
Some((run_at_max, w_at_max, h_at_max, max_pt_f32))
} else {
None
};
if best.is_none() {
let mut lo = min_pt_f32;
let mut hi = max_pt_f32;
for _ in 0..MAX_ITERS {
let mid = 0.5 * (lo + hi);
let (run, w, h) = measure(mid);
if w <= rect_w && h <= rect_h {
lo = mid;
best = Some((run, w, h, mid));
} else {
hi = mid;
}
}
}
let (run, content_w, content_h, _size_pt, fits) = match best {
Some((r, w, h, s)) => (r, w, h, s, true),
None => {
let style = make_style(min_pt_f32);
let run = TextRun::new(&text, &style, ctx.dpi);
run.set_max_width(rect_w as f32, justify_x);
let w = run.content_width();
let h = run.current_height();
(run, w, h, min_pt_f32, false)
}
};
let draw_x = rect.x0;
let vslack = (rect_h - content_h).max(0.0);
let draw_y = rect.y0 + justify_y_frac * vslack;
let fill_color = override_alpha(
resolve_color_channel_or_theme(
fill_ch,
fill_scale,
i,
ctx.theme.geom.text_fit.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_fit.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_fit.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 angle = resolve_angle_channel(angle_ch, angle_scale, i);
let xform = if angle == 0.0 {
Affine::IDENTITY
} else {
let cx = 0.5 * (rect.x0 + rect.x1);
let cy = 0.5 * (rect.y0 + rect.y1);
Affine::rotate_about(-angle, Point::new(cx, cy))
};
let need_clip = !fits;
if need_clip {
let clip_path = rect_path(rect);
scene.push_layer(crate::blend::BlendMode::NORMAL, 1.0, xform, &clip_path);
}
if bg_fill.is_some() || bg_stroke.is_some() {
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 = content_w + 2.0 * padding_px;
let bg_h = content_h + top_pad_eff + bottom_pad_eff;
let bg_left = draw_x - padding_px;
let bg_top = draw_y - top_pad_eff;
let bg_rect = Rect::new(bg_left, bg_top, bg_left + bg_w, bg_top + bg_h);
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)
};
let bg_xform = if need_clip { Affine::IDENTITY } else { xform };
if let Some(fc) = bg_fill {
scene.fill(
FillRule::NonZero,
bg_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_fit.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,
bg_xform,
&Brush::Solid(sc),
None,
&bg_path,
pick,
);
}
}
}
}
let glyph_xform = if need_clip { Affine::IDENTITY } else { xform };
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,
glyph_xform,
crate::pick::PickId::Skip,
);
}
}
draw_text(
scene,
&run,
draw_x,
draw_y,
&Brush::Solid(fill_color),
glyph_xform,
pick,
);
if need_clip {
scene.pop_layer();
}
}
}
}
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_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,
}
}
fn resolve_justify_x(
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_justify_y_frac(
channel: Option<&Channel>,
scale: Option<&crate::plot::scale::Scale>,
i: usize,
) -> f64 {
let s = match resolve_str_channel(channel, scale, i) {
Some(s) => s,
None => return 0.0,
};
match s.as_str() {
"start" => 0.0,
"center" | "centre" | "middle" => 0.5,
"end" => 1.0,
_ => 0.0,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::color::Color;
use crate::plot::geom::DirectScaleResolver;
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)
}
#[test]
fn build_requires_text() {
let r = std::panic::catch_unwind(|| {
TextFitGeom::builder()
.set("x", vec![0.0_f64])
.set("y", vec![0.0_f64])
.set("x2", vec![1.0_f64])
.set("y2", vec![1.0_f64])
.build()
});
assert!(r.is_err());
}
#[test]
fn fit_into_wide_rect_emits_glyphs() {
let g = TextFitGeom::builder()
.set("x", vec![0.1_f64])
.set("y", vec![0.3_f64])
.set("x2", vec![0.9_f64])
.set("y2", vec![0.7_f64])
.set("text", vec!["abc"])
.set("fill", Color::new([0.0, 0.0, 0.0, 1.0]))
.build();
let panel = Rect::new(0.0, 0.0, 600.0, 200.0);
let shapes = shapes();
let resolver = DirectScaleResolver::new();
let mut scene = RecordingScene::default();
g.draw(&mut scene, &ctx(panel, &shapes, &resolver));
let glyph_ops = scene
.ops
.iter()
.filter(|op| matches!(op, Op::DrawGlyphs(_)))
.count();
assert!(glyph_ops >= 1, "expected at least one glyph op");
}
#[test]
fn min_font_overflow_pushes_clip_layer() {
let g = TextFitGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("x2", vec![0.51_f64])
.set("y2", vec![0.55_f64])
.set("text", vec!["overflowing text"])
.set("min_font_size", 8.0_f64)
.set("max_font_size", 9.0_f64)
.set("fill", Color::new([0.0, 0.0, 0.0, 1.0]))
.build();
let panel = Rect::new(0.0, 0.0, 400.0, 200.0);
let shapes = shapes();
let resolver = DirectScaleResolver::new();
let mut scene = RecordingScene::default();
g.draw(&mut scene, &ctx(panel, &shapes, &resolver));
let push_layers = scene
.ops
.iter()
.filter(|op| matches!(op, Op::PushLayer { .. }))
.count();
let pop_layers = scene
.ops
.iter()
.filter(|op| matches!(op, Op::PopLayer))
.count();
assert!(push_layers >= 1, "expected push_layer for clip");
assert_eq!(push_layers, pop_layers, "push/pop must balance");
}
#[test]
fn justify_y_end_shifts_text_to_bottom() {
let mk = |justify: &'static str| {
TextFitGeom::builder()
.set("x", vec![0.2_f64])
.set("y", vec![0.05_f64])
.set("x2", vec![0.8_f64])
.set("y2", vec![0.95_f64])
.set("text", vec!["abc"])
.set("fill", Color::new([0.0, 0.0, 0.0, 1.0]))
.set("justify_y", justify)
.set("max_font_size", 14.0_f64)
.build()
};
let panel = Rect::new(0.0, 0.0, 400.0, 300.0);
let shapes = shapes();
let resolver = DirectScaleResolver::new();
let mut s_start = RecordingScene::default();
mk("start").draw(&mut s_start, &ctx(panel, &shapes, &resolver));
let mut s_end = RecordingScene::default();
mk("end").draw(&mut s_end, &ctx(panel, &shapes, &resolver));
let first_glyph_y = |scene: &RecordingScene| {
scene.ops.iter().find_map(|op| match op {
Op::DrawGlyphs(gr) => gr.glyphs.first().map(|g| g.y),
_ => None,
})
};
let y_start = first_glyph_y(&s_start).expect("start case glyph");
let y_end = first_glyph_y(&s_end).expect("end case glyph");
assert!(
y_end > y_start,
"justify_y=end should place glyphs lower (larger y in screen): start={} end={}",
y_start,
y_end
);
}
fn first_glyph_font_size(scene: &RecordingScene) -> Option<f32> {
scene.ops.iter().find_map(|op| match op {
Op::DrawGlyphs(gr) => Some(gr.font_size),
_ => None,
})
}
#[test]
fn text_that_already_fits_draws_at_the_maximum_font_size() {
let g = TextFitGeom::builder()
.set("x", vec![0.05_f64])
.set("y", vec![0.05_f64])
.set("x2", vec![0.95_f64])
.set("y2", vec![0.95_f64])
.set("text", vec!["ab"])
.set("min_font_size", 6.0_f64)
.set("max_font_size", 12.0_f64)
.set("fill", Color::new([0.0, 0.0, 0.0, 1.0]))
.build();
let panel = Rect::new(0.0, 0.0, 600.0, 400.0);
let shapes = shapes();
let resolver = DirectScaleResolver::new();
let mut scene = RecordingScene::default();
g.draw(&mut scene, &ctx(panel, &shapes, &resolver));
let size = first_glyph_font_size(&scene).expect("expected glyphs");
assert!((size - 16.0).abs() < 1e-3, "{size}");
}
#[test]
fn text_that_never_fits_draws_at_the_minimum_font_size() {
let g = TextFitGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("x2", vec![0.51_f64])
.set("y2", vec![0.55_f64])
.set("text", vec!["overflowing text"])
.set("min_font_size", 9.0_f64)
.set("max_font_size", 40.0_f64)
.set("fill", Color::new([0.0, 0.0, 0.0, 1.0]))
.build();
let panel = Rect::new(0.0, 0.0, 400.0, 200.0);
let shapes = shapes();
let resolver = DirectScaleResolver::new();
let mut scene = RecordingScene::default();
g.draw(&mut scene, &ctx(panel, &shapes, &resolver));
let size = first_glyph_font_size(&scene).expect("expected glyphs");
assert!((size - 12.0).abs() < 1e-3, "{size}");
}
#[test]
fn the_search_lands_between_the_two_bounds_when_only_part_of_the_range_fits() {
let g = TextFitGeom::builder()
.set("x", vec![0.1_f64])
.set("y", vec![0.4_f64])
.set("x2", vec![0.9_f64])
.set("y2", vec![0.6_f64])
.set("text", vec!["a fitted label"])
.set("min_font_size", 6.0_f64)
.set("max_font_size", 200.0_f64)
.set("fill", Color::new([0.0, 0.0, 0.0, 1.0]))
.build();
let panel = Rect::new(0.0, 0.0, 400.0, 200.0);
let shapes = shapes();
let resolver = DirectScaleResolver::new();
let mut scene = RecordingScene::default();
g.draw(&mut scene, &ctx(panel, &shapes, &resolver));
let size = first_glyph_font_size(&scene).expect("expected glyphs");
let px = |pt: f64| (pt * 96.0 / 72.0) as f32;
assert!(size > px(6.0) && size < px(200.0), "{size}");
assert!(!scene
.ops
.iter()
.any(|op| matches!(op, Op::PushLayer { .. })));
}
#[test]
fn justify_y_center_places_the_block_midway_through_the_slack() {
let mk = |justify: &'static str| {
TextFitGeom::builder()
.set("x", vec![0.2_f64])
.set("y", vec![0.05_f64])
.set("x2", vec![0.8_f64])
.set("y2", vec![0.95_f64])
.set("text", vec!["abc"])
.set("fill", Color::new([0.0, 0.0, 0.0, 1.0]))
.set("justify_y", justify)
.set("max_font_size", 14.0_f64)
.build()
};
let panel = Rect::new(0.0, 0.0, 400.0, 300.0);
let shapes = shapes();
let resolver = DirectScaleResolver::new();
let glyph_y = |justify: &'static str| {
let mut scene = RecordingScene::default();
mk(justify).draw(&mut scene, &ctx(panel, &shapes, &resolver));
scene
.ops
.iter()
.find_map(|op| match op {
Op::DrawGlyphs(gr) => gr.glyphs.first().map(|g| g.y),
_ => None,
})
.expect("expected glyphs")
};
let (start, center, end) = (glyph_y("start"), glyph_y("center"), glyph_y("end"));
assert!(end > start, "start={start} end={end}");
let expected = 0.5 * (start + end);
assert!(
(center - expected).abs() < 1e-3,
"center={center} expected={expected}"
);
}
#[test]
fn justify_y_frac_covers_the_alignment_vocabulary() {
let frac = |s: &'static str| {
let ch = Channel::Constant(Value::from(s));
resolve_justify_y_frac(Some(&ch), None, 0)
};
assert_eq!(frac("start"), 0.0);
assert_eq!(frac("center"), 0.5);
assert_eq!(frac("centre"), 0.5);
assert_eq!(frac("middle"), 0.5);
assert_eq!(frac("end"), 1.0);
assert_eq!(frac("sideways"), 0.0);
assert_eq!(resolve_justify_y_frac(None, None, 0), 0.0);
}
#[test]
fn justify_x_covers_the_alignment_vocabulary() {
let align = |s: &'static str| {
let ch = Channel::Constant(Value::from(s));
resolve_justify_x(Some(&ch), None, 0)
};
assert!(matches!(align("start"), HAlign::Start));
assert!(matches!(align("center"), HAlign::Center));
assert!(matches!(align("middle"), HAlign::Center));
assert!(matches!(align("end"), HAlign::End));
assert!(matches!(align("justify"), HAlign::Justify));
assert!(matches!(align("justified"), HAlign::Justify));
assert!(matches!(align("sideways"), HAlign::Start));
assert!(matches!(resolve_justify_x(None, None, 0), HAlign::Start));
}
}