pub struct TextRun { /* private fields */ }Expand description
Shaped text — built once, re-laid-out cheaply on width changes.
Implements Measure so it can be dropped into a
crate::composition::Patch::slot via
crate::layout::Cell::measured.
Implementations§
Source§impl TextRun
impl TextRun
Sourcepub fn new(text: &str, style: &TextStyle, dpi: f64) -> Self
pub fn new(text: &str, style: &TextStyle, dpi: f64) -> Self
Shape text with style at dpi (typically 96 for screen output).
The point-size on style is converted to pixels via
size_px = size_pt * dpi / 72 before parley shapes the glyphs.
Full shaping cost is paid here; later calls to
Measure::height_at and draw_text only re-break lines.
Examples found in repository?
19fn text_cell(text: &str, size: f32) -> Cell {
20 Cell::measured(TextRun::new(text, &TextStyle::new(size), 96.0))
21}
22
23fn weighted_text_cell(text: &str, size: f32, weight: u16) -> Cell {
24 Cell::measured(TextRun::new(
25 text,
26 &TextStyle::new(size).weight(weight),
27 96.0,
28 ))
29}
30
31fn color_for_region(region: &str) -> Color {
32 match region {
33 "panel" => rgb8(40, 50, 70),
34 "title" => rgb8(220, 180, 90),
35 "subtitle" => rgb8(180, 150, 70),
36 "caption" => rgb8(180, 130, 90),
37 "axis_left" | "axis_right" | "axis_top" | "axis_bottom" => rgb8(160, 100, 130),
38 "axis_left_title" | "axis_right_title" | "axis_top_title" | "axis_bottom_title" => {
39 rgb8(200, 120, 160)
40 }
41 "strip_left" | "strip_right" | "strip_top" | "strip_bottom" => rgb8(90, 140, 180),
42 "legend_left" | "legend_right" | "legend_top" | "legend_bottom" => rgb8(110, 180, 140),
43 _ => rgb8(120, 120, 120),
44 }
45}
46
47/// Build an inner patch with axis labels and a panel-axis title. Each
48/// `TextRun` provides its own intrinsic width/height to the layout solver.
49fn build_inner_patch(id: &str, y_axis_text: &str, y_axis_title: &str, x_axis_title: &str) -> Patch {
50 Patch::new(id)
51 .slot(Slot::AxisLeft, text_cell(y_axis_text, 12.0))
52 .slot(
53 Slot::AxisLeftTitle,
54 weighted_text_cell(y_axis_title, 13.0, 500),
55 )
56 .slot(Slot::AxisBottom, text_cell("0 25 50 75 100", 12.0))
57 .slot(
58 Slot::AxisBottomTitle,
59 weighted_text_cell(x_axis_title, 13.0, 500),
60 )
61 .slot(Slot::Panel, Cell::empty())
62}
63
64fn main() {
65 let (w, h) = (1200u32, 700u32);
66 let dpi = 96.0;
67
68 let inner_a = build_inner_patch("plot_a", "0\n50\n100", "Pressure (kPa)", "Time (s)");
69 let inner_b = build_inner_patch(
70 "plot_b",
71 "0\n10000\n20000\n30000",
72 "Particle count",
73 "Time (s)",
74 );
75
76 // Header carries title + subtitle; footer carries caption. Both have no
77 // panel content — their composition rows are sized to chrome height only
78 // (Fr(0.0) panel weight so the middle row absorbs all leftover height).
79 let header = Patch::new("header")
80 .slot(
81 Slot::Title,
82 weighted_text_cell("Reactor diagnostics", 28.0, 700),
83 )
84 .slot(
85 Slot::Subtitle,
86 text_cell("Pressure and particle count over the morning run", 16.0),
87 );
88 let footer = Patch::new("footer").slot(
89 Slot::Caption,
90 text_cell("Source: in-line telemetry, smoothed at 1s intervals", 11.0),
91 );
92 let composed = Composition::empty(3, 1)
93 .heights(vec![Track::Fr(0.0), Track::Fr(1.0), Track::Fr(0.0)])
94 .place(1, 1, Span::cell(), header)
95 .place(2, 1, Span::cell(), beside(inner_a, inner_b))
96 .place(3, 1, Span::cell(), footer);
97
98 let layout = composed.solve(hephaestus::Size::new(w as f64, h as f64), dpi);
99
100 let mut renderer = VelloRenderer::new().expect("vello renderer init");
101 {
102 let scene = renderer.scene();
103 let stroke = Stroke::new(1.0);
104 let text_brush: Brush = rgb8(30, 30, 40).into();
105
106 // Background colour rectangles for chrome.
107 for (_id, region, rect) in layout.iter() {
108 if region == "panel" {
109 continue;
110 }
111 let color = color_for_region(region);
112 let tinted = Color::new([
113 color.components[0],
114 color.components[1],
115 color.components[2],
116 0.18,
117 ]);
118 let path: Path = rect.to_path(0.1);
119 scene.fill(
120 FillRule::NonZero,
121 Affine::IDENTITY,
122 &Brush::Solid(tinted),
123 None,
124 &path,
125 PickId::Skip,
126 );
127 scene.stroke(
128 &stroke,
129 Affine::IDENTITY,
130 &Brush::Solid(color),
131 None,
132 &path,
133 PickId::Skip,
134 );
135 }
136
137 // Panels on top.
138 for (_id, region, rect) in layout.iter() {
139 if region != "panel" {
140 continue;
141 }
142 let path: Path = rect.to_path(0.1);
143 scene.fill(
144 FillRule::NonZero,
145 Affine::IDENTITY,
146 &Brush::Solid(color_for_region(region)),
147 None,
148 &path,
149 PickId::Skip,
150 );
151 scene.stroke(
152 &stroke,
153 Affine::IDENTITY,
154 &Brush::Solid(rgb8(255, 255, 255)),
155 None,
156 &path,
157 PickId::Skip,
158 );
159 }
160
161 // Draw the actual text. We reach into the patches again to get a
162 // `TextRun` reference — the composition layout doesn't hand us the
163 // measure values back. For the demo, just rebuild a separate TextRun
164 // for each slot keyed by the same text so we can render it.
165 for (id, region, rect) in layout.iter() {
166 if region == "panel" {
167 continue;
168 }
169 let text = match (id, region) {
170 ("header", "title") => "Reactor diagnostics",
171 ("header", "subtitle") => "Pressure and particle count over the morning run",
172 ("footer", "caption") => "Source: in-line telemetry, smoothed at 1s intervals",
173 ("plot_a", "axis_left") => "0\n50\n100",
174 ("plot_a", "axis_left_title") => "Pressure (kPa)",
175 ("plot_a", "axis_bottom") => "0 25 50 75 100",
176 ("plot_a", "axis_bottom_title") => "Time (s)",
177 ("plot_b", "axis_left") => "0\n10000\n20000\n30000",
178 ("plot_b", "axis_left_title") => "Particle count",
179 ("plot_b", "axis_bottom") => "0 25 50 75 100",
180 ("plot_b", "axis_bottom_title") => "Time (s)",
181 _ => continue,
182 };
183 let size = match region {
184 "title" => 28.0,
185 "subtitle" => 16.0,
186 "caption" => 11.0,
187 "axis_left_title" | "axis_bottom_title" => 13.0,
188 _ => 12.0,
189 };
190 let weight = match region {
191 "title" => 700,
192 "axis_left_title" | "axis_bottom_title" => 500,
193 _ => 400,
194 };
195 let style = TextStyle::new(size).weight(weight);
196 let run = TextRun::new(text, &style, 96.0);
197 draw_text_in_rect(scene, &run, rect, &text_brush, PickId::Skip);
198 }
199 }
200
201 let mut pixels = vec![0u8; (w * h * 4) as usize];
202 let bg: Color = rgb8(248, 248, 252);
203 renderer
204 .render_to_buffer(w, h, bg, &mut pixels)
205 .expect("render");
206
207 let path = std::env::current_dir()
208 .unwrap()
209 .join("examples/composition_demo.png");
210 hephaestus::image::write_png(&path, w, h, &pixels).expect("write png");
211 println!("wrote {}", path.display());
212
213 let panel_a = layout.get("plot_a", Slot::Panel).unwrap();
214 let panel_b = layout.get("plot_b", Slot::Panel).unwrap();
215 let title = layout.get("header", Slot::Title).unwrap();
216 println!("plot_a.panel = {:?}", panel_a);
217 println!("plot_b.panel = {:?}", panel_b);
218 println!("header.title = {:?}", title);
219}More examples
21fn text_cell(text: &str, size: f32) -> Cell {
22 Cell::measured(TextRun::new(text, &TextStyle::new(size), 96.0))
23}
24
25fn plot(id: &str, title: &str, axis_left: &str, axis_bottom: &str) -> Patch {
26 Patch::new(id)
27 .slot(Slot::Title, text_cell(title, 16.0))
28 .slot(Slot::AxisLeft, text_cell(axis_left, 11.0))
29 .slot(Slot::AxisBottom, text_cell(axis_bottom, 11.0))
30 .slot(Slot::Panel, Cell::empty())
31}
32
33fn color_for_region(region: &str) -> Color {
34 match region {
35 "panel" => rgb8(40, 60, 90),
36 "title" => rgb8(220, 180, 90),
37 "axis_left" | "axis_bottom" => rgb8(160, 100, 130),
38 _ => rgb8(120, 120, 120),
39 }
40}
41
42fn main() {
43 let (w, h) = (1200u32, 700u32);
44 let dpi = 96.0;
45
46 let row_three = grid(
47 1,
48 3,
49 vec![
50 plot("a1", "Series A1", "0\n10\n20", "0 5 10").into(),
51 plot("a2", "Series A2", "0\n50\n100", "0 5 10").into(),
52 plot("a3", "Series A3", "0\n500\n1000", "0 5 10").into(),
53 ],
54 );
55 let row_two = grid(
56 1,
57 2,
58 vec![
59 plot("b1", "Series B1 (wider)", "0\n100\n200\n300", "0 25 50").into(),
60 plot("b2", "Series B2 (wider)", "0\n5000\n10000", "0 25 50").into(),
61 ],
62 );
63 let composed = stack(row_three, row_two);
64 let layout = composed.solve(hephaestus::Size::new(w as f64, h as f64), dpi);
65
66 let mut renderer = VelloRenderer::new().expect("vello renderer init");
67 {
68 let scene = renderer.scene();
69 let stroke = hephaestus::stroke::Stroke::new(1.0);
70 let text_brush: Brush = rgb8(20, 20, 30).into();
71
72 // Chrome rects, tinted background.
73 for (_id, region, rect) in layout.iter() {
74 if region == "panel" {
75 continue;
76 }
77 let c = color_for_region(region);
78 let tint = Color::new([c.components[0], c.components[1], c.components[2], 0.20]);
79 let path: Path = rect.to_path(0.1);
80 scene.fill(
81 FillRule::NonZero,
82 Affine::IDENTITY,
83 &Brush::Solid(tint),
84 None,
85 &path,
86 PickId::Skip,
87 );
88 scene.stroke(
89 &stroke,
90 Affine::IDENTITY,
91 &Brush::Solid(c),
92 None,
93 &path,
94 PickId::Skip,
95 );
96 }
97
98 // Panels solid.
99 for (_id, region, rect) in layout.iter() {
100 if region != "panel" {
101 continue;
102 }
103 let path: Path = rect.to_path(0.1);
104 scene.fill(
105 FillRule::NonZero,
106 Affine::IDENTITY,
107 &Brush::Solid(color_for_region(region)),
108 None,
109 &path,
110 PickId::Skip,
111 );
112 scene.stroke(
113 &stroke,
114 Affine::IDENTITY,
115 &Brush::Solid(rgb8(255, 255, 255)),
116 None,
117 &path,
118 PickId::Skip,
119 );
120 }
121
122 // Text labels in chrome.
123 for (id, region, rect) in layout.iter() {
124 if region == "panel" {
125 continue;
126 }
127 let text = match (id, region) {
128 ("a1", "title") => "Series A1",
129 ("a2", "title") => "Series A2",
130 ("a3", "title") => "Series A3",
131 ("b1", "title") => "Series B1 (wider)",
132 ("b2", "title") => "Series B2 (wider)",
133 ("a1", "axis_left") => "0\n10\n20",
134 ("a2", "axis_left") => "0\n50\n100",
135 ("a3", "axis_left") => "0\n500\n1000",
136 ("b1", "axis_left") => "0\n100\n200\n300",
137 ("b2", "axis_left") => "0\n5000\n10000",
138 (_, "axis_bottom") if id.starts_with('a') => "0 5 10",
139 (_, "axis_bottom") if id.starts_with('b') => "0 25 50",
140 _ => continue,
141 };
142 let size = if region == "title" { 16.0 } else { 11.0 };
143 let run = TextRun::new(text, &TextStyle::new(size), 96.0);
144 draw_text_in_rect(scene, &run, rect, &text_brush, PickId::Skip);
145 }
146 }
147
148 let mut pixels = vec![0u8; (w * h * 4) as usize];
149 let bg: Color = rgb8(248, 248, 252);
150 renderer
151 .render_to_buffer(w, h, bg, &mut pixels)
152 .expect("render");
153
154 let path = std::env::current_dir()
155 .unwrap()
156 .join("examples/nesting_asymmetric.png");
157 hephaestus::image::write_png(&path, w, h, &pixels).expect("write png");
158 println!("wrote {}", path.display());
159}35fn text_cell(text: &str, size: f32) -> Cell {
36 Cell::measured(TextRun::new(text, &TextStyle::new(size), 96.0))
37}
38
39fn plain_plot(id: &str) -> Patch {
40 Patch::new(id).slot(Slot::Panel, Cell::empty())
41}
42
43fn plot_with_axis_top(id: &str, axis_top_text: &str) -> Patch {
44 Patch::new(id)
45 .slot(Slot::AxisTop, text_cell(axis_top_text, 14.0))
46 .slot(Slot::Panel, Cell::empty())
47}
48
49fn color_for_region(region: &str) -> Color {
50 match region {
51 "panel" => rgb8(40, 60, 90),
52 "axis_top" => rgb8(200, 100, 130),
53 _ => rgb8(120, 120, 120),
54 }
55}
56
57fn main() {
58 let (w, h) = (1400u32, 400u32);
59 let dpi = 96.0;
60
61 let plain = plain_plot("plain");
62 let nested = grid(
63 1,
64 3,
65 vec![
66 plain_plot("c1").into(),
67 plot_with_axis_top("c2", "this is a TALL axis_top label\nspanning multiple lines\nto stress chrome propagation").into(),
68 plain_plot("c3").into(),
69 ],
70 );
71 let composed = beside(plain, nested);
72 let layout = composed.solve(hephaestus::Size::new(w as f64, h as f64), dpi);
73
74 let mut renderer = VelloRenderer::new().expect("vello renderer init");
75 {
76 let scene = renderer.scene();
77 let stroke = hephaestus::stroke::Stroke::new(1.0);
78 let text_brush: Brush = rgb8(20, 20, 30).into();
79
80 for (_id, region, rect) in layout.iter() {
81 if region == "panel" {
82 continue;
83 }
84 let c = color_for_region(region);
85 let tint = Color::new([c.components[0], c.components[1], c.components[2], 0.20]);
86 let path: Path = rect.to_path(0.1);
87 scene.fill(
88 FillRule::NonZero,
89 Affine::IDENTITY,
90 &Brush::Solid(tint),
91 None,
92 &path,
93 PickId::Skip,
94 );
95 scene.stroke(
96 &stroke,
97 Affine::IDENTITY,
98 &Brush::Solid(c),
99 None,
100 &path,
101 PickId::Skip,
102 );
103 }
104 for (_id, region, rect) in layout.iter() {
105 if region != "panel" {
106 continue;
107 }
108 let path: Path = rect.to_path(0.1);
109 scene.fill(
110 FillRule::NonZero,
111 Affine::IDENTITY,
112 &Brush::Solid(color_for_region(region)),
113 None,
114 &path,
115 PickId::Skip,
116 );
117 scene.stroke(
118 &stroke,
119 Affine::IDENTITY,
120 &Brush::Solid(rgb8(255, 255, 255)),
121 None,
122 &path,
123 PickId::Skip,
124 );
125 }
126 // Label the only chrome that has text content.
127 if let Some(rect) = layout.get("c2", Slot::AxisTop) {
128 let run = TextRun::new(
129 "this is a TALL axis_top label\nspanning multiple lines\nto stress chrome propagation",
130 &TextStyle::new(14.0),
131 96.0,
132 );
133 draw_text_in_rect(scene, &run, rect, &text_brush, PickId::Skip);
134 }
135 }
136
137 let mut pixels = vec![0u8; (w * h * 4) as usize];
138 let bg: Color = rgb8(248, 248, 252);
139 renderer
140 .render_to_buffer(w, h, bg, &mut pixels)
141 .expect("render");
142
143 let path = std::env::current_dir()
144 .unwrap()
145 .join("examples/nesting_chrome_coupling.png");
146 hephaestus::image::write_png(&path, w, h, &pixels).expect("write png");
147 println!("wrote {}", path.display());
148
149 // Print the panel y-coordinates so the user can sanity-check that
150 // the plain panel starts at the same y as the inner panels even
151 // though it has no axis_top.
152 let plain_y = layout.get("plain", Slot::Panel).unwrap();
153 let c1_y = layout.get("c1", Slot::Panel).unwrap();
154 let c2_y = layout.get("c2", Slot::Panel).unwrap();
155 let c3_y = layout.get("c3", Slot::Panel).unwrap();
156 println!(
157 "panel y0: plain={}, c1={}, c2={}, c3={}",
158 plain_y.y0, c1_y.y0, c2_y.y0, c3_y.y0
159 );
160 println!("(all four should be equal — bidirectional sizer coupling at work)");
161}25fn text_cell(text: &str, size: f32) -> Cell {
26 Cell::measured(TextRun::new(text, &TextStyle::new(size), 96.0))
27}
28
29fn plain(id: &str) -> Patch {
30 Patch::new(id).slot(Slot::Panel, Cell::empty())
31}
32
33fn color_for_region(region: &str) -> Color {
34 match region {
35 "panel" => rgb8(40, 60, 90),
36 "axis_top" => rgb8(200, 100, 130),
37 "axis_bottom" => rgb8(160, 100, 130),
38 _ => rgb8(120, 120, 120),
39 }
40}
41
42fn main() {
43 let (w, h) = (1400u32, 400u32);
44 let dpi = 96.0;
45
46 // Deepest level: two plots, the first carries the chrome that needs
47 // to propagate all the way up to the root composition.
48 let leaf = beside(
49 Patch::new("leaf_l")
50 .slot(Slot::AxisTop, text_cell("axis_top from deepest leaf", 14.0))
51 .slot(Slot::AxisBottom, text_cell("axis_bottom from leaf", 11.0))
52 .slot(Slot::Panel, Cell::empty()),
53 plain("leaf_r"),
54 );
55 // Mid level: a plain plot beside the leaf composition.
56 let mid = beside(plain("mid"), leaf);
57 // Root level: a plain plot beside the mid composition.
58 let composed = beside(plain("root"), mid);
59
60 let layout = composed.solve(hephaestus::Size::new(w as f64, h as f64), dpi);
61
62 let mut renderer = VelloRenderer::new().expect("vello renderer init");
63 {
64 let scene = renderer.scene();
65 let stroke = hephaestus::stroke::Stroke::new(1.0);
66 let text_brush: Brush = rgb8(20, 20, 30).into();
67
68 for (_id, region, rect) in layout.iter() {
69 if region == "panel" {
70 continue;
71 }
72 let c = color_for_region(region);
73 let tint = Color::new([c.components[0], c.components[1], c.components[2], 0.20]);
74 let path: Path = rect.to_path(0.1);
75 scene.fill(
76 FillRule::NonZero,
77 Affine::IDENTITY,
78 &Brush::Solid(tint),
79 None,
80 &path,
81 PickId::Skip,
82 );
83 scene.stroke(
84 &stroke,
85 Affine::IDENTITY,
86 &Brush::Solid(c),
87 None,
88 &path,
89 PickId::Skip,
90 );
91 }
92 for (_id, region, rect) in layout.iter() {
93 if region != "panel" {
94 continue;
95 }
96 let path: Path = rect.to_path(0.1);
97 scene.fill(
98 FillRule::NonZero,
99 Affine::IDENTITY,
100 &Brush::Solid(color_for_region(region)),
101 None,
102 &path,
103 PickId::Skip,
104 );
105 scene.stroke(
106 &stroke,
107 Affine::IDENTITY,
108 &Brush::Solid(rgb8(255, 255, 255)),
109 None,
110 &path,
111 PickId::Skip,
112 );
113 }
114
115 if let Some(rect) = layout.get("leaf_l", Slot::AxisTop) {
116 let run = TextRun::new("axis_top from deepest leaf", &TextStyle::new(14.0), 96.0);
117 draw_text_in_rect(scene, &run, rect, &text_brush, PickId::Skip);
118 }
119 if let Some(rect) = layout.get("leaf_l", Slot::AxisBottom) {
120 let run = TextRun::new("axis_bottom from leaf", &TextStyle::new(11.0), 96.0);
121 draw_text_in_rect(scene, &run, rect, &text_brush, PickId::Skip);
122 }
123 }
124
125 let mut pixels = vec![0u8; (w * h * 4) as usize];
126 let bg: Color = rgb8(248, 248, 252);
127 renderer
128 .render_to_buffer(w, h, bg, &mut pixels)
129 .expect("render");
130
131 let path = std::env::current_dir()
132 .unwrap()
133 .join("examples/nesting_deep.png");
134 hephaestus::image::write_png(&path, w, h, &pixels).expect("write png");
135 println!("wrote {}", path.display());
136
137 // Print panel y0s so the user can verify alignment across all 3 nesting levels.
138 let root_panel = layout.get("root", Slot::Panel).unwrap();
139 let mid_panel = layout.get("mid", Slot::Panel).unwrap();
140 let leaf_l_panel = layout.get("leaf_l", Slot::Panel).unwrap();
141 let leaf_r_panel = layout.get("leaf_r", Slot::Panel).unwrap();
142 println!(
143 "panel y0: root={}, mid={}, leaf_l={}, leaf_r={}",
144 root_panel.y0, mid_panel.y0, leaf_l_panel.y0, leaf_r_panel.y0
145 );
146 println!("(all four should be equal — propagation across 3 nesting levels)");
147}32fn text_cell(text: &str, size: f32) -> Cell {
33 Cell::measured(TextRun::new(text, &TextStyle::new(size), 96.0))
34}
35
36fn weighted_text_cell(text: &str, size: f32, weight: u16) -> Cell {
37 Cell::measured(TextRun::new(
38 text,
39 &TextStyle::new(size).weight(weight),
40 96.0,
41 ))
42}
43
44fn facet(id: &str, axis_left: &str, axis_bottom: &str) -> Patch {
45 Patch::new(id)
46 .slot(Slot::AxisLeft, text_cell(axis_left, 11.0))
47 .slot(Slot::AxisBottom, text_cell(axis_bottom, 11.0))
48 .slot(Slot::Panel, Cell::empty())
49}
50
51fn color_for_region(region: &str) -> Color {
52 match region {
53 "panel" => rgb8(40, 60, 90),
54 "title" => rgb8(220, 180, 90),
55 "subtitle" => rgb8(180, 150, 70),
56 "caption" => rgb8(180, 130, 90),
57 "axis_left" | "axis_bottom" => rgb8(160, 100, 130),
58 _ => rgb8(120, 120, 120),
59 }
60}
61
62fn main() {
63 let (w, h) = (1400u32, 800u32);
64 let dpi = 96.0;
65
66 // A 2×3 grid of facets, each with its own axis chrome. The
67 // composition is then annotated with shared chrome via `.slot(...)`
68 // directly — no wrapper composition, no manual span calculations.
69 let mut facet_cells = Vec::new();
70 for r in 1..=2 {
71 for c in 1..=3 {
72 let id = format!("f{}_{}", r, c);
73 facet_cells.push(facet(&id, "0\n50\n100", "0 25 50").into());
74 }
75 }
76 let composed = grid(2, 3, facet_cells)
77 .id("plot")
78 .slot(
79 Slot::Title,
80 weighted_text_cell("Reactor diagnostics across runs", 26.0, 700),
81 )
82 .slot(
83 Slot::Subtitle,
84 text_cell(
85 "Each panel: a separate 60-minute run; same scales throughout",
86 15.0,
87 ),
88 )
89 .slot(
90 Slot::AxisLeftTitle,
91 weighted_text_cell("Pressure (kPa)", 14.0, 500),
92 )
93 .slot(
94 Slot::AxisBottomTitle,
95 weighted_text_cell("Elapsed time (minutes)", 14.0, 500),
96 )
97 .slot(
98 Slot::Caption,
99 text_cell(
100 "Source: in-line telemetry, smoothed at 1s; runs ordered chronologically.",
101 11.0,
102 ),
103 );
104
105 let layout = composed.solve(hephaestus::Size::new(w as f64, h as f64), dpi);
106
107 let mut renderer = VelloRenderer::new().expect("vello renderer init");
108 {
109 let scene = renderer.scene();
110 let stroke = hephaestus::stroke::Stroke::new(1.0);
111 let text_brush: Brush = rgb8(20, 20, 30).into();
112
113 for (_id, region, rect) in layout.iter() {
114 if region == "panel" {
115 continue;
116 }
117 let c = color_for_region(region);
118 let tint = Color::new([c.components[0], c.components[1], c.components[2], 0.20]);
119 let path: Path = rect.to_path(0.1);
120 scene.fill(
121 FillRule::NonZero,
122 Affine::IDENTITY,
123 &Brush::Solid(tint),
124 None,
125 &path,
126 PickId::Skip,
127 );
128 scene.stroke(
129 &stroke,
130 Affine::IDENTITY,
131 &Brush::Solid(c),
132 None,
133 &path,
134 PickId::Skip,
135 );
136 }
137 for (_id, region, rect) in layout.iter() {
138 if region != "panel" {
139 continue;
140 }
141 let path: Path = rect.to_path(0.1);
142 scene.fill(
143 FillRule::NonZero,
144 Affine::IDENTITY,
145 &Brush::Solid(color_for_region(region)),
146 None,
147 &path,
148 PickId::Skip,
149 );
150 scene.stroke(
151 &stroke,
152 Affine::IDENTITY,
153 &Brush::Solid(rgb8(255, 255, 255)),
154 None,
155 &path,
156 PickId::Skip,
157 );
158 }
159
160 for (id, region, rect) in layout.iter() {
161 let (text, size, weight) = match (id, region) {
162 ("plot", "title") => ("Reactor diagnostics across runs", 26.0, 700),
163 ("plot", "subtitle") => (
164 "Each panel: a separate 60-minute run; same scales throughout",
165 15.0,
166 400,
167 ),
168 ("plot", "caption") => (
169 "Source: in-line telemetry, smoothed at 1s; runs ordered chronologically.",
170 11.0,
171 400,
172 ),
173 ("plot", "axis_left_title") => ("Pressure (kPa)", 14.0, 500),
174 ("plot", "axis_bottom_title") => ("Elapsed time (minutes)", 14.0, 500),
175 (_, "axis_left") => ("0\n50\n100", 11.0, 400),
176 (_, "axis_bottom") => ("0 25 50", 11.0, 400),
177 _ => continue,
178 };
179 let run = TextRun::new(text, &TextStyle::new(size).weight(weight), 96.0);
180 draw_text_in_rect(scene, &run, rect, &text_brush, PickId::Skip);
181 }
182 }
183
184 let mut pixels = vec![0u8; (w * h * 4) as usize];
185 let bg: Color = rgb8(248, 248, 252);
186 renderer
187 .render_to_buffer(w, h, bg, &mut pixels)
188 .expect("render");
189
190 let path = std::env::current_dir()
191 .unwrap()
192 .join("examples/nesting_faceted_title.png");
193 hephaestus::image::write_png(&path, w, h, &pixels).expect("write png");
194 println!("wrote {}", path.display());
195
196 // Sanity check: chrome rects line up with the facet panel band.
197 let title = layout.get("plot", Slot::Title).unwrap();
198 let caption = layout.get("plot", Slot::Caption).unwrap();
199 let axis_left_title = layout.get("plot", Slot::AxisLeftTitle).unwrap();
200 let axis_bottom_title = layout.get("plot", Slot::AxisBottomTitle).unwrap();
201 let f1_1_panel = layout.get("f1_1", Slot::Panel).unwrap();
202 let f1_3_panel = layout.get("f1_3", Slot::Panel).unwrap();
203 let f2_3_panel = layout.get("f2_3", Slot::Panel).unwrap();
204 println!(
205 "plot.title: x0={:>5.0} x1={:>5.0} y0={:>5.0} y1={:>5.0}",
206 title.x0, title.x1, title.y0, title.y1
207 );
208 println!(
209 "plot.caption: x0={:>5.0} x1={:>5.0} y0={:>5.0} y1={:>5.0}",
210 caption.x0, caption.x1, caption.y0, caption.y1
211 );
212 println!(
213 "plot.axis_left_title: x0={:>5.0} x1={:>5.0} y0={:>5.0} y1={:>5.0}",
214 axis_left_title.x0, axis_left_title.x1, axis_left_title.y0, axis_left_title.y1
215 );
216 println!(
217 "plot.axis_bottom_title: x0={:>5.0} x1={:>5.0} y0={:>5.0} y1={:>5.0}",
218 axis_bottom_title.x0, axis_bottom_title.x1, axis_bottom_title.y0, axis_bottom_title.y1
219 );
220 println!(
221 "facets panel band: x0={:>5.0} x1={:>5.0} y0={:>5.0} y1={:>5.0}",
222 f1_1_panel.x0, f1_3_panel.x1, f1_1_panel.y0, f2_3_panel.y1
223 );
224}43fn text_cell(text: &str, size: f32) -> Cell {
44 Cell::measured(TextRun::new(text, &TextStyle::new(size), 96.0))
45}
46
47fn plain(id: &str) -> Patch {
48 Patch::new(id).slot(Slot::Panel, Cell::empty())
49}
50
51fn fixed_square(id: &str, label: &str) -> Patch {
52 // Chrome on a fixed patch is fine — the solver's second iteration
53 // picks up the resolved Auto-row heights from iter 0 and reshapes
54 // the respected fr distribution to honour the lock anyway. The
55 // axis_top here proves it: panels still report ratio = 1.000.
56 Patch::new(id)
57 .aspect(1.0, 1.0)
58 .slot(Slot::AxisTop, text_cell(label, 12.0))
59 .slot(Slot::Panel, Cell::empty())
60}
61
62fn color_for(id: &str, region: &str) -> Color {
63 match (id, region) {
64 (_, "panel") if id.starts_with("fixed") => rgb8(80, 140, 80), // green for locked
65 (_, "panel") => rgb8(40, 60, 90), // blue for flex
66 (_, "axis_top") => rgb8(200, 100, 130),
67 _ => rgb8(120, 120, 120),
68 }
69}
70
71fn main() {
72 // Wide viewport so the lock-vs-flex difference is obvious.
73 let (w, h) = (1600u32, 400u32);
74 let dpi = 96.0;
75
76 // Three flex plots in a 2-level beside chain — flex_a is at the
77 // outer level beside the deeper composition; flex_b and flex_c sit
78 // one level deeper.
79 let flex_chain = beside(plain("flex_a"), beside(plain("flex_b"), plain("flex_c")));
80 // Both fixed-aspect plots sit in the SAME outermost composition row
81 // as the flex chain. `Composition::beside` extends an existing 1-row
82 // composition by appending a cell — all five end up as direct
83 // siblings of the same outer grid, sharing one panel row.
84 let composed = beside(fixed_square("fixed_l", "1:1"), flex_chain)
85 .append_col(fixed_square("fixed_r", "1:1"));
86
87 let layout = composed.solve(hephaestus::Size::new(w as f64, h as f64), dpi);
88
89 let mut renderer = VelloRenderer::new().expect("vello renderer init");
90 {
91 let scene = renderer.scene();
92 let stroke = hephaestus::stroke::Stroke::new(1.0);
93 let text_brush: Brush = rgb8(20, 20, 30).into();
94
95 // Chrome first (axes / etc.).
96 for (id, region, rect) in layout.iter() {
97 if region == "panel" {
98 continue;
99 }
100 let c = color_for(id, region);
101 let tint = Color::new([c.components[0], c.components[1], c.components[2], 0.20]);
102 let path: Path = rect.to_path(0.1);
103 scene.fill(
104 FillRule::NonZero,
105 Affine::IDENTITY,
106 &Brush::Solid(tint),
107 None,
108 &path,
109 PickId::Skip,
110 );
111 scene.stroke(
112 &stroke,
113 Affine::IDENTITY,
114 &Brush::Solid(c),
115 None,
116 &path,
117 PickId::Skip,
118 );
119 }
120 // Panels (with green for locked, blue for flex).
121 for (id, region, rect) in layout.iter() {
122 if region != "panel" {
123 continue;
124 }
125 let path: Path = rect.to_path(0.1);
126 scene.fill(
127 FillRule::NonZero,
128 Affine::IDENTITY,
129 &Brush::Solid(color_for(id, region)),
130 None,
131 &path,
132 PickId::Skip,
133 );
134 scene.stroke(
135 &stroke,
136 Affine::IDENTITY,
137 &Brush::Solid(rgb8(255, 255, 255)),
138 None,
139 &path,
140 PickId::Skip,
141 );
142 }
143 // Panel-centre labels noting locked vs flex + actual width:height.
144 for (id, region, rect) in layout.iter() {
145 if region != "panel" {
146 continue;
147 }
148 let w = rect.x1 - rect.x0;
149 let h = rect.y1 - rect.y0;
150 let label = if id.starts_with("fixed") {
151 format!("{id}\nlocked 1:1\n{w:.0}×{h:.0}")
152 } else {
153 format!("{id}\nflex\n{w:.0}×{h:.0}")
154 };
155 let run = TextRun::new(&label, &TextStyle::new(13.0).weight(500), 96.0);
156 let brush: Brush = rgb8(255, 255, 255).into();
157 draw_text_in_rect(scene, &run, rect, &brush, PickId::Skip);
158 }
159 // Axis-top labels on the two fixed patches.
160 for id in &["fixed_l", "fixed_r"] {
161 if let Some(rect) = layout.get(id, Slot::AxisTop) {
162 let run = TextRun::new(&format!("{id} 1:1"), &TextStyle::new(12.0), 96.0);
163 draw_text_in_rect(scene, &run, rect, &text_brush, PickId::Skip);
164 }
165 }
166 }
167
168 let mut pixels = vec![0u8; (w * h * 4) as usize];
169 let bg: Color = rgb8(248, 248, 252);
170 renderer
171 .render_to_buffer(w, h, bg, &mut pixels)
172 .expect("render");
173
174 let path = std::env::current_dir()
175 .unwrap()
176 .join("examples/nesting_fixed_aspect.png");
177 hephaestus::image::write_png(&path, w, h, &pixels).expect("write png");
178 println!("wrote {}", path.display());
179
180 // Print per-panel widths/heights so the aspect locks are verifiable
181 // from a terminal too. All five panels should report the same
182 // height (alignment across the outer row); fixed_l and fixed_r
183 // additionally have width == height (locked 1:1).
184 for id in &["fixed_l", "flex_a", "flex_b", "flex_c", "fixed_r"] {
185 let p = layout.get(id, Slot::Panel).unwrap();
186 let pw = p.x1 - p.x0;
187 let ph = p.y1 - p.y0;
188 let ratio = pw / ph;
189 let tag = if id.starts_with("fixed") {
190 "locked 1:1"
191 } else {
192 "flex"
193 };
194 println!("{id:>8} ({tag:>10}): w={pw:>5.1} h={ph:>5.1} ratio={ratio:.3}");
195 }
196 println!("(all five panels share one outer row → all heights equal;");
197 println!(" fixed_l and fixed_r additionally report ratio ≈ 1.000.)");
198}Sourcepub fn set_max_width(&self, max_width: f32, alignment: HAlign) -> f32
pub fn set_max_width(&self, max_width: f32, alignment: HAlign) -> f32
Re-break lines at max_width pixels, applying alignment to the
resulting layout. Equivalent to Measure::height_at(max_width, _)
but exposed for callers that want to draw without first running
through a composition solve.
alignment controls justification within the wrap box:
HAlign::Start leaves every line flush against the leading
edge; Center / End / Justify apply the matching
direction-aware alignment.
Sourcepub fn natural_width(&self) -> f64
pub fn natural_width(&self) -> f64
Natural unwrapped content width in pixels — the width the text
would occupy if laid out on a single line per paragraph break in
the source. Computed once at construction; stable regardless of
subsequent Self::set_max_width calls. Used by label-style
geoms to anchor the text against its intrinsic dimensions.
Sourcepub fn natural_height(&self) -> f64
pub fn natural_height(&self) -> f64
Natural unwrapped content height in pixels. Stable across
Self::set_max_width calls.
Sourcepub fn current_height(&self) -> f64
pub fn current_height(&self) -> f64
Current laid-out height in pixels — reflects the most recent
Self::set_max_width / Measure::height_at call. Equals
Self::natural_height when no wrap has been requested.
Sourcepub fn content_width(&self) -> f64
pub fn content_width(&self) -> f64
Actual rendered content width in pixels — the widest line in
the current layout. Reflects the most recent line-break, so
when Self::set_max_width has been called the result is the
actual wrapped width (usually less than the constraint, since
parley breaks at word boundaries). When no wrap has been
requested the layout is single-line and this matches
Self::natural_width.
Sourcepub fn baseline_offset(&self) -> f64
pub fn baseline_offset(&self) -> f64
Offset from the layout’s top edge to the baseline of the
first line, in pixels. Differs from the font’s ascent
metric when the resolved line height includes leading — the
baseline sits below the typographic ascent by half the
leading. Used by chrome labels to convert between
baseline-anchored and top-anchored positioning.
Sourcepub fn cap_height(&self) -> f64
pub fn cap_height(&self) -> f64
Cap-height of the first run, in pixels — distance from the
baseline to the top of capital letters. Falls back to
x_height (and then 0.7 × ascent as a last resort) when
the font doesn’t report cap-height. Used by axis / legend
label centering: a numeric or uppercase label centered on
cap_height looks visually balanced against its tick or
swatch, whereas centering on the full natural_height
reserves descender space the glyphs don’t occupy and shifts
the visual centre off-target.
Sourcepub fn last_line_descender(&self) -> f64
pub fn last_line_descender(&self) -> f64
Font descender of the last line in the current layout, in
pixels. Used by background-rect geoms to apply the
ggplot2 geom_label-style padding rebalance — bump top padding
up to at least the descender and reduce bottom padding by the
same — so visible glyphs centre vertically in the rect even
when the last line has no descenders (“men” vs “jay”).
Sourcepub fn first_line_ascender_offset(&self) -> f64
pub fn first_line_ascender_offset(&self) -> f64
Y position of the first line’s ascender top, relative to the
layout’s top edge. Equivalent to the top half-leading on the
first line — the empty pixels above the visible glyphs that
the line-box reserves on its way to line-height.
Sourcepub fn last_line_descender_offset_from_top(&self) -> f64
pub fn last_line_descender_offset_from_top(&self) -> f64
Y position of the last line’s descender bottom, relative to
the layout’s top edge. Equivalent to current_height - bottom half-leading on the last line.
Sourcepub fn inked_height(&self) -> f64
pub fn inked_height(&self) -> f64
Inked height of the current layout — from the first line’s ascender top to the last line’s descender bottom, with leading appearing only between lines (not above the first or below the last). The natural text box.
Trait Implementations§
Source§impl Measure for TextRun
impl Measure for TextRun
Source§fn width_hint(&self, _dpi: f64) -> WidthHint
fn width_hint(&self, _dpi: f64) -> WidthHint
WidthHint::Min) or a height-dependent value that opts the
leaf into iteration (WidthHint::NeedsHeight).Source§fn height_at(&self, width: f64, _dpi: f64) -> f64
fn height_at(&self, width: f64, _dpi: f64) -> f64
width
pixels.Source§fn width_at(&self, _height: f64, _dpi: f64) -> f64
fn width_at(&self, _height: f64, _dpi: f64) -> f64
WidthHint::NeedsHeight.
Default 0.0 is correct for content that uses
WidthHint::Min.Auto Trait Implementations§
impl !Freeze for TextRun
impl !RefUnwindSafe for TextRun
impl !Sync for TextRun
impl !UnwindSafe for TextRun
impl Send for TextRun
impl Unpin for TextRun
impl UnsafeUnpin for TextRun
Blanket Implementations§
Source§impl<T> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
Source§impl<T> Downcast for Twhere
T: Any,
impl<T> Downcast for Twhere
T: Any,
Source§fn into_any(self: Box<T>) -> Box<dyn Any>
fn into_any(self: Box<T>) -> Box<dyn Any>
Box<dyn Trait> (where Trait: Downcast) to Box<dyn Any>. Box<dyn Any> can
then be further downcast into Box<ConcreteType> where ConcreteType implements Trait.Source§fn into_any_rc(self: Rc<T>) -> Rc<dyn Any>
fn into_any_rc(self: Rc<T>) -> Rc<dyn Any>
Rc<Trait> (where Trait: Downcast) to Rc<Any>. Rc<Any> can then be
further downcast into Rc<ConcreteType> where ConcreteType implements Trait.Source§fn as_any(&self) -> &(dyn Any + 'static)
fn as_any(&self) -> &(dyn Any + 'static)
&Trait (where Trait: Downcast) to &Any. This is needed since Rust cannot
generate &Any’s vtable from &Trait’s.Source§fn as_any_mut(&mut self) -> &mut (dyn Any + 'static)
fn as_any_mut(&mut self) -> &mut (dyn Any + 'static)
&mut Trait (where Trait: Downcast) to &Any. This is needed since Rust cannot
generate &mut Any’s vtable from &mut Trait’s.