nesting_fixed_aspect/nesting_fixed_aspect.rs
1//! Visual demo: fixed-aspect plots composed with deeply-nested flex
2//! plots, all sharing the same outer composition row.
3//!
4//! Layout: a 1×5 outer composition with `fixed_l` and `fixed_r` at the
5//! two ends (both `.aspect(1, 1)`), and three flex plots in the middle
6//! arranged in a 3-level-deep beside chain
7//! (`beside(flex_a, beside(flex_b, flex_c))`).
8//!
9//! ```text
10//! ┌─────────┬─────────┬──────┬──────┬─────────┐
11//! │ fixed_l │ flex_a │flex_b│flex_c│ fixed_r │
12//! └─────────┴─────────┴──────┴──────┴─────────┘
13//! ```
14//!
15//! Both fixed-aspect plots and all flex plots share the **same outer
16//! row**, so all five panel tops/bottoms align. Selective respect on
17//! the outer grid couples `fixed_l` and `fixed_r`'s panel cells at
18//! 1:1; the three flex panel columns (one direct child plus a 2-deep
19//! nested composition) absorb the horizontal slack at their respective
20//! nesting levels.
21//!
22//! Compare against `nesting_deep.png` — same horizontal layout style,
23//! but with two aspect-locked anchors and chrome on the locked panels.
24//!
25//! Note on alignment: fixed-aspect plots align with their siblings
26//! when they share the outermost composition row (this example).
27//! Putting one fixed plot at the outer level and another deep inside a
28//! nested composition currently produces different panel heights —
29//! the forward/back sizer chain couples chrome rows across nesting but
30//! not the panel row. Best practice for v1.5: keep fixed-aspect plots
31//! at the same composition level so they share the same panel row.
32//!
33//! Writes `examples/nesting_fixed_aspect.png`.
34
35use hephaestus::backend::vello::VelloRenderer;
36use hephaestus::color::{rgb8, Color};
37use hephaestus::composition::{beside, Patch, Slot};
38use hephaestus::layout::Cell;
39use hephaestus::text::{draw_text_in_rect, TextRun, TextStyle};
40use hephaestus::{Affine, Brush, FillRule, Path, PickId, Renderer, SceneBuilder};
41use kurbo::Shape;
42
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}