1use std::path::Path;
9
10use base64::Engine as _;
11use serde::{Deserialize, Serialize};
12use yog_gfx::{Color, Rect, Vec2};
13
14use super::assets::Resources;
15use super::{gfx, images, raster};
16
17const SCREEN: (f32, f32) = (427.0, 240.0);
19const WINDOW: (f32, f32) = (252.0, 140.0);
21const VIEW: Rect = Rect { x: 9.0, y: 18.0, w: 234.0, h: 113.0 };
22const TITLE_AT: Vec2 = Vec2 { x: 8.0, y: 6.0 };
24const STEP: (f32, f32) = (28.0, 27.0);
26const FRAME: f32 = 26.0;
27const FRAME_DX: f32 = 3.0;
28const ICON_AT: Vec2 = Vec2 { x: 8.0, y: 5.0 };
29const ICON: f32 = 16.0;
30const TILE: f32 = 16.0;
32const CONNECT_MID: f32 = 13.0;
34const CONNECT_OUT: f32 = FRAME + 4.0;
35const BAR_W: f32 = 200.0;
38const TITLE_MAX: f32 = 163.0;
39const BAR_TEXT: Vec2 = Vec2 { x: 32.0, y: 9.0 };
40const TEXT_PAD_LEFT: f32 = 3.0;
41const TEXT_PAD_RIGHT: f32 = 5.0;
42const TEXT_LEFT: f32 = 5.0;
43const LINE_H: f32 = 9.0;
44const TITLE_BAR_MIN: f32 = 29.0;
45const DESCRIPTION_TOP: f32 = 17.0;
46const DESCRIPTION_TOP_UP: f32 = 7.0;
48const DESCRIPTION_PAD: f32 = 32.0;
49const FLIP_ROOM: f32 = 6.0;
50const SPLIT_OFFSETS: [f32; 5] = [0.0, 10.0, -10.0, 25.0, -25.0];
53const SPLIT_GOOD_ENOUGH: f32 = 10.0;
54const HOVER_FADE: Color = Color(0x4C00_0000);
56const BACKDROP: (Color, Color) = (Color(0xC010_1010), Color(0xD010_1010));
58const TITLE_COLOR: Color = Color(0xFF40_4040);
59const WHITE: Color = Color(0xFFFF_FFFF);
60const BLACK: Color = Color(0xFF00_0000);
61const SCALE: u32 = 1;
63
64#[derive(Deserialize, Debug, Clone)]
66pub struct Node {
67 pub id: String,
68 pub parent: Option<String>,
69 pub icon: Option<String>,
70 pub frame: String,
72 pub title: String,
73 pub description: String,
74 #[serde(default)]
76 pub obtained: bool,
77}
78
79#[derive(Deserialize, Debug, Clone)]
81pub struct Args {
82 pub path: String,
83 pub title: String,
84 pub background: Option<String>,
86 pub nodes: Vec<Node>,
87 #[serde(default)]
89 pub drag: [f32; 2],
90 pub hover: Option<String>,
92}
93
94#[derive(Serialize, Debug, Clone, PartialEq)]
96pub struct Spot {
97 pub id: String,
98 pub rect: [f32; 4],
99}
100
101#[derive(Serialize, Debug, Clone)]
103pub struct Rendered {
104 pub image: Option<String>,
105 pub size: [f32; 2],
106 pub view: [f32; 4],
108 pub spots: Vec<Spot>,
109 pub drag: [f32; 2],
111}
112
113pub fn render(args: &Args) -> Rendered {
115 let root = super::mod_root(Path::new(&args.path));
116 images::set_document(root.as_deref());
117 let res = Resources::for_mod(root.as_deref());
118 let at = layout(&args.nodes);
119 let place = |id: &str| at.iter().find(|(i, _)| i == id).map(|(_, p)| Vec2::new((p.0 * STEP.0).floor(), (p.1 * STEP.1).floor()));
120 let spots: Vec<(usize, Vec2)> = args.nodes.iter().enumerate().filter_map(|(i, n)| Some((i, place(&n.id)?))).collect();
121 let min_x = spots.iter().map(|(_, p)| p.x).fold(f32::MAX, f32::min);
123 let max_x = spots.iter().map(|(_, p)| p.x + STEP.0).fold(f32::MIN, f32::max);
124 let min_y = spots.iter().map(|(_, p)| p.y).fold(f32::MAX, f32::min);
125 let max_y = spots.iter().map(|(_, p)| p.y + STEP.1).fold(f32::MIN, f32::max);
126 let centre = Vec2::new((VIEW.w / 2.0 - ((max_x + min_x) / 2.0).trunc()).trunc(), ((VIEW.h / 2.0).floor() - ((max_y + min_y) / 2.0).trunc()).trunc());
127 let axis = |centre: f32, drag: f32, extent: f32, min: f32, view: f32| {
128 if extent - min > view { (centre + drag).clamp(-(extent - view), 0.0) - centre } else { 0.0 }
129 };
130 let drag = [axis(centre.x, args.drag[0], max_x, min_x, VIEW.w), axis(centre.y, args.drag[1], max_y, min_y, VIEW.h)];
131 let scroll = Vec2::new((centre.x + drag[0]).floor(), (centre.y + drag[1]).floor());
132 let window = Vec2::new(((SCREEN.0 - WINDOW.0) / 2.0).floor(), ((SCREEN.1 - WINDOW.1) / 2.0).floor());
133 let view = Vec2::new(window.x + VIEW.x, window.y + VIEW.y);
134 let origin = Vec2::new(view.x + scroll.x, view.y + scroll.y);
135
136 let ops = gfx::record(SCREEN.0 as i32, SCREEN.1 as i32, |ctx| {
137 let d = ctx.draw2d();
138 d.rect(Rect::new(0.0, 0.0, SCREEN.0, SCREEN.1), BLACK);
140 d.gradient(Rect::new(0.0, 0.0, SCREEN.0, SCREEN.1), BACKDROP.0, BACKDROP.1);
141 let background = args.background.as_deref().map_or_else(|| yog_ids::game::MISSING_TEXTURE.to_string(), yog_gfx::image::short_source);
143 let (ox, oy) = (scroll.x % TILE, scroll.y % TILE);
144 for i in -1..=(VIEW.w / TILE) as i32 {
145 for j in -1..=(VIEW.h / TILE) as i32 {
146 let tile = Rect::new(view.x + ox + TILE * i as f32, view.y + oy + TILE * j as f32, TILE, TILE);
147 if let Some(t) = clip(tile, Rect::new(view.x, view.y, VIEW.w, VIEW.h)) {
148 let uv = Rect::new(t.x - tile.x, t.y - tile.y, t.w, t.h);
149 d.image_region(&background, t, Some(uv), WHITE);
150 }
151 }
152 }
153 let view_rect = Rect::new(view.x, view.y, VIEW.w, VIEW.h);
154 let fill = |r: Rect, c: Color| {
155 if let Some(r) = clip(r, view_rect) {
156 d.rect(r, c);
157 }
158 };
159 for shadow in [true, false] {
160 for (i, p) in &spots {
161 let Some(pp) = args.nodes[*i].parent.as_deref().and_then(place) else { continue };
162 connector(&fill, origin, pp, *p, shadow);
163 }
164 }
165 for (i, p) in &spots {
166 let n = &args.nodes[*i];
167 let frame = Rect::new(origin.x + p.x + FRAME_DX, origin.y + p.y, FRAME, FRAME);
168 if clip(frame, view_rect).is_some() {
169 d.image(&frame_sprite(&n.frame, n.obtained), frame);
170 if let Some(icon) = &n.icon {
171 d.item(icon, Vec2::new(origin.x + p.x + ICON_AT.x, origin.y + p.y + ICON_AT.y), ICON);
172 }
173 }
174 }
175 d.image(WINDOW_SPRITE, Rect::new(window.x, window.y, WINDOW.0, WINDOW.1));
176 d.text(&args.title, Vec2::new(window.x + TITLE_AT.x, window.y + TITLE_AT.y), TITLE_COLOR, false);
177 if let Some((i, p)) = args.hover.as_deref().and_then(|h| spots.iter().find(|(i, _)| args.nodes[*i].id == h)) {
179 d.rect(view_rect, HOVER_FADE);
180 hover(&d, &args.nodes[*i], origin, *p, window);
181 }
182 });
183 let canvas = raster::rasterize(&ops, SCREEN.0 as u32, SCREEN.1 as u32, SCALE, &res);
184 let image = canvas.png().ok().map(|png| format!("data:image/png;base64,{}", base64::engine::general_purpose::STANDARD.encode(png)));
185 Rendered {
186 image,
187 size: [SCREEN.0, SCREEN.1],
188 view: [view.x, view.y, VIEW.w, VIEW.h],
189 spots: spots
190 .iter()
191 .map(|(i, p)| Spot { id: args.nodes[*i].id.clone(), rect: [origin.x + p.x, origin.y + p.y, FRAME + 1.0, FRAME + 1.0] })
192 .collect(),
193 drag,
194 }
195}
196
197const WINDOW_SPRITE: &str = "sprite:advancement_window";
199fn frame_sprite(frame: &str, obtained: bool) -> String {
200 format!("sprite:advancement/{frame}{}", if obtained { "_obtained" } else { "" })
201}
202fn bar_sprite(obtained: bool) -> &'static str {
203 if obtained { "sprite:advancement/box_obtained" } else { "sprite:advancement/box" }
204}
205const DESCRIPTION_SPRITE: &str = "sprite:advancement/description";
206
207const CHALLENGE: &str = "challenge";
209const DARK_PURPLE: char = '5';
210const GREEN: char = 'a';
211
212fn description_color(frame: &str) -> Color {
214 let code = if frame == CHALLENGE { DARK_PURPLE } else { GREEN };
215 Color(super::font::chat_color(code).unwrap_or(Color::WHITE.0))
216}
217
218fn connector(fill: &impl Fn(Rect, Color), o: Vec2, parent: Vec2, child: Vec2, shadow: bool) {
220 let (i, j, k) = (o.x + parent.x + CONNECT_MID, o.x + parent.x + CONNECT_OUT, o.y + parent.y + CONNECT_MID);
221 let (l, m) = (o.x + child.x + CONNECT_MID, o.y + child.y + CONNECT_MID);
222 let h = |a: f32, b: f32, y: f32, c: Color| fill(Rect::new(a.min(b), y, (a - b).abs() + 1.0, 1.0), c);
223 let v = |x: f32, a: f32, b: f32, c: Color| fill(Rect::new(x, a.min(b) + 1.0, 1.0, ((a - b).abs() - 1.0).max(0.0)), c);
224 if shadow {
225 h(j, i, k - 1.0, BLACK);
226 h(j + 1.0, i, k, BLACK);
227 h(j, i, k + 1.0, BLACK);
228 h(l, j - 1.0, m - 1.0, BLACK);
229 h(l, j - 1.0, m, BLACK);
230 h(l, j - 1.0, m + 1.0, BLACK);
231 v(j - 1.0, m, k, BLACK);
232 v(j + 1.0, m, k, BLACK);
233 } else {
234 h(j, i, k, WHITE);
235 h(l, j, m, WHITE);
236 v(j, m, k, WHITE);
237 }
238}
239
240fn hover(d: &yog_gfx::draw2d::Draw2D<'_>, n: &Node, o: Vec2, p: Vec2, window: Vec2) {
242 let width_of = |s: &str| d.text_width(s, "");
243 let title = cut_to_width(&n.title, TITLE_MAX, &width_of);
244 let mut inner = TITLE_BAR_MIN + width_of(&title);
245 let lines = optimal_lines(&n.description, inner, &width_of);
246 for l in &lines {
247 inner = inner.max(width_of(l));
248 }
249 let width = inner + TEXT_PAD_LEFT + TEXT_PAD_RIGHT;
250 let y = o.y + p.y;
251 let flip = o.x + p.x + width + FRAME >= SCREEN.0;
253 let up = VIEW.h - (o.y - window.y - VIEW.y) - p.y - FRAME <= FLIP_ROOM + lines.len() as f32 * LINE_H;
254 let x = if flip { o.x + p.x - width + FRAME + FLIP_ROOM } else { o.x + p.x };
255 let box_h = DESCRIPTION_PAD + lines.len() as f32 * LINE_H;
256 if !lines.is_empty() {
257 let top = if up { y + FRAME - box_h } else { y };
258 d.image(DESCRIPTION_SPRITE, Rect::new(x, top, width, box_h));
259 }
260 let half = (width / 2.0).floor();
261 let rest = width - half;
262 d.image_region(bar_sprite(n.obtained), Rect::new(x, y, half, FRAME), Some(Rect::new(0.0, 0.0, half, FRAME)), WHITE);
263 d.image_region(bar_sprite(n.obtained), Rect::new(x + half, y, rest, FRAME), Some(Rect::new(BAR_W - rest, 0.0, rest, FRAME)), WHITE);
264 d.image(&frame_sprite(&n.frame, n.obtained), Rect::new(o.x + p.x + FRAME_DX, y, FRAME, FRAME));
265 let title_x = if flip { x + TEXT_LEFT } else { o.x + p.x + BAR_TEXT.x };
266 d.text(&title, Vec2::new(title_x, y + BAR_TEXT.y), WHITE, true);
267 let color = description_color(&n.frame);
268 for (i, line) in lines.iter().enumerate() {
269 let ly = if up { y + FRAME - box_h + DESCRIPTION_TOP_UP + i as f32 * LINE_H } else { y + BAR_TEXT.y + DESCRIPTION_TOP + i as f32 * LINE_H };
270 d.text(line, Vec2::new(x + TEXT_LEFT, ly), color, false);
271 }
272 if let Some(icon) = &n.icon {
273 d.item(icon, Vec2::new(o.x + p.x + ICON_AT.x, y + ICON_AT.y), ICON);
274 }
275}
276
277fn cut_to_width(s: &str, max: f32, width: &impl Fn(&str) -> f32) -> String {
279 let mut end = s.len();
280 while end > 0 && width(&s[..end]) > max {
281 end = s[..end].char_indices().last().map_or(0, |(i, _)| i);
282 }
283 s[..end].to_string()
284}
285
286fn optimal_lines(text: &str, target: f32, width: &impl Fn(&str) -> f32) -> Vec<String> {
289 if text.is_empty() {
290 return Vec::new();
291 }
292 let mut best: Option<(f32, Vec<String>)> = None;
293 for off in SPLIT_OFFSETS {
294 let lines = split_lines(text, target - off, width);
295 let gap = (lines.iter().map(|l| width(l)).fold(0.0, f32::max) - target).abs();
296 if gap <= SPLIT_GOOD_ENOUGH {
297 return lines;
298 }
299 if best.as_ref().is_none_or(|(g, _)| gap < *g) {
300 best = Some((gap, lines));
301 }
302 }
303 best.map(|(_, l)| l).unwrap_or_default()
304}
305
306fn split_lines(text: &str, max: f32, width: &impl Fn(&str) -> f32) -> Vec<String> {
309 let mut out = Vec::new();
310 for para in text.split('\n') {
311 let mut line = String::new();
312 for word in para.split(' ') {
313 let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
314 if width(&candidate) <= max || line.is_empty() && width(word) <= max {
315 line = candidate;
316 continue;
317 }
318 if !line.is_empty() {
319 out.push(std::mem::take(&mut line));
320 }
321 let mut rest = word;
322 while width(rest) > max {
323 let cut = cut_to_width(rest, max, width);
324 if cut.is_empty() {
325 break;
326 }
327 out.push(cut.clone());
328 rest = &rest[cut.len()..];
329 }
330 line = rest.to_string();
331 }
332 out.push(line);
333 }
334 out
335}
336
337fn clip(r: Rect, within: Rect) -> Option<Rect> {
339 let (x0, y0) = (r.x.max(within.x), r.y.max(within.y));
340 let (x1, y1) = ((r.x + r.w).min(within.x + within.w), (r.y + r.h).min(within.y + within.h));
341 (x1 > x0 && y1 > y0).then(|| Rect::new(x0, y0, x1 - x0, y1 - y0))
342}
343
344pub fn layout(nodes: &[Node]) -> Vec<(String, (f32, f32))> {
348 let ids: Vec<&str> = nodes.iter().map(|n| n.id.as_str()).collect();
349 let mut tree = Tree::default();
350 let mut out = Vec::new();
351 let mut offset = 0.0;
352 for root in nodes.iter().filter(|n| parent_in(n, &ids).is_none()) {
353 let r = tree.build(&root.id, None, None, 1, nodes, &ids);
354 tree.first_walk(r);
355 let top = tree.second_walk(r, 0.0, 0, tree.n[r].y);
356 if top < 0.0 {
357 tree.third_walk(r, -top);
358 }
359 let mut bottom: f32 = 0.0;
360 tree.each(r, &mut |n| {
361 out.push((n.id.clone(), (n.x as f32, n.y + offset)));
362 bottom = bottom.max(n.y);
363 });
364 offset += bottom + 1.0;
365 }
366 for n in nodes {
368 if !out.iter().any(|(id, _)| *id == n.id) {
369 out.push((n.id.clone(), (0.0, offset)));
370 offset += 1.0;
371 }
372 }
373 out
374}
375
376fn parent_in<'a>(n: &'a Node, ids: &[&str]) -> Option<&'a str> {
378 n.parent.as_deref().filter(|p| ids.contains(p) && *p != n.id)
379}
380
381#[derive(Default)]
382struct Tree {
383 n: Vec<TreeNode>,
384}
385
386struct TreeNode {
387 id: String,
388 parent: Option<usize>,
389 previous: Option<usize>,
390 child_index: usize,
391 children: Vec<usize>,
392 ancestor: usize,
393 thread: Option<usize>,
394 x: i32,
395 y: f32,
396 modifier: f32,
397 change: f32,
398 shift: f32,
399}
400
401impl Tree {
402 fn build(&mut self, id: &str, parent: Option<usize>, previous: Option<usize>, child_index: usize, nodes: &[Node], ids: &[&str]) -> usize {
403 let me = self.n.len();
404 let x = parent.map_or(0, |p| self.n[p].x + 1);
405 self.n.push(TreeNode { id: id.to_string(), parent, previous, child_index, children: Vec::new(), ancestor: me, thread: None, x, y: -1.0, modifier: 0.0, change: 0.0, shift: 0.0 });
406 let mut prev = None;
407 for child in nodes.iter().filter(|c| parent_in(c, ids) == Some(id)) {
408 if self.n.iter().any(|t| t.id == child.id) {
409 continue;
410 }
411 let index = self.n[me].children.len() + 1;
412 let c = self.build(&child.id, Some(me), prev, index, nodes, ids);
413 self.n[me].children.push(c);
414 prev = Some(c);
415 }
416 me
417 }
418
419 fn each(&self, at: usize, f: &mut impl FnMut(&TreeNode)) {
420 f(&self.n[at]);
421 for &c in &self.n[at].children.clone() {
422 self.each(c, f);
423 }
424 }
425
426 fn first_walk(&mut self, v: usize) {
427 let children = self.n[v].children.clone();
428 if children.is_empty() {
429 self.n[v].y = self.n[v].previous.map_or(0.0, |p| self.n[p].y + 1.0);
430 return;
431 }
432 let mut default = None;
433 for &c in &children {
434 self.first_walk(c);
435 default = Some(self.apportion(c, default.unwrap_or(c)));
436 }
437 self.execute_shifts(v);
438 let mid = (self.n[children[0]].y + self.n[*children.last().expect("not empty")].y) / 2.0;
439 match self.n[v].previous {
440 Some(p) => {
441 self.n[v].y = self.n[p].y + 1.0;
442 self.n[v].modifier = self.n[v].y - mid;
443 }
444 None => self.n[v].y = mid,
445 }
446 }
447
448 fn second_walk(&mut self, v: usize, offset: f32, column: i32, mut top: f32) -> f32 {
449 self.n[v].y += offset;
450 self.n[v].x = column;
451 top = top.min(self.n[v].y);
452 let m = self.n[v].modifier;
453 for c in self.n[v].children.clone() {
454 top = self.second_walk(c, offset + m, column + 1, top);
455 }
456 top
457 }
458
459 fn third_walk(&mut self, v: usize, y: f32) {
460 self.n[v].y += y;
461 for c in self.n[v].children.clone() {
462 self.third_walk(c, y);
463 }
464 }
465
466 fn execute_shifts(&mut self, v: usize) {
467 let (mut shift, mut change) = (0.0, 0.0);
468 for &c in self.n[v].children.clone().iter().rev() {
469 self.n[c].y += shift;
470 self.n[c].modifier += shift;
471 change += self.n[c].change;
472 shift += self.n[c].shift + change;
473 }
474 }
475
476 fn previous_or_thread(&self, v: usize) -> Option<usize> {
477 self.n[v].thread.or_else(|| self.n[v].children.first().copied())
478 }
479
480 fn next_or_thread(&self, v: usize) -> Option<usize> {
481 self.n[v].thread.or_else(|| self.n[v].children.last().copied())
482 }
483
484 fn apportion(&mut self, v: usize, mut default: usize) -> usize {
485 let (Some(previous), Some(parent)) = (self.n[v].previous, self.n[v].parent) else { return default };
486 let (mut in_right, mut out_right, mut in_left, mut out_left) = (v, v, previous, self.n[parent].children[0]);
487 let (mut s_in_right, mut s_out_right) = (self.n[v].modifier, self.n[v].modifier);
488 let (mut s_in_left, mut s_out_left) = (self.n[in_left].modifier, self.n[out_left].modifier);
489 while let (Some(nl), Some(pr)) = (self.next_or_thread(in_left), self.previous_or_thread(in_right)) {
490 in_left = nl;
491 in_right = pr;
492 out_left = self.previous_or_thread(out_left).expect("the left contour is as deep");
493 out_right = self.next_or_thread(out_right).expect("the right contour is as deep");
494 self.n[out_right].ancestor = v;
495 let gap = self.n[in_left].y + s_in_left - (self.n[in_right].y + s_in_right) + 1.0;
496 if gap > 0.0 {
497 let a = self.ancestor_of(in_left, v, default);
498 self.move_subtree(a, v, gap);
499 s_in_right += gap;
500 s_out_right += gap;
501 }
502 s_in_left += self.n[in_left].modifier;
503 s_in_right += self.n[in_right].modifier;
504 s_out_left += self.n[out_left].modifier;
505 s_out_right += self.n[out_right].modifier;
506 }
507 if self.next_or_thread(in_left).is_some() && self.next_or_thread(out_right).is_none() {
508 self.n[out_right].thread = self.next_or_thread(in_left);
509 self.n[out_right].modifier += s_in_left - s_out_right;
510 } else {
511 if self.previous_or_thread(in_right).is_some() && self.previous_or_thread(out_left).is_none() {
512 self.n[out_left].thread = self.previous_or_thread(in_right);
513 self.n[out_left].modifier += s_in_right - s_out_left;
514 }
515 default = v;
516 }
517 default
518 }
519
520 fn ancestor_of(&self, of: usize, v: usize, other: usize) -> usize {
521 let a = self.n[of].ancestor;
522 let siblings = self.n[v].parent.map(|p| &self.n[p].children);
523 if siblings.is_some_and(|s| s.contains(&a)) { a } else { other }
524 }
525
526 fn move_subtree(&mut self, from: usize, to: usize, shift: f32) {
527 let subtrees = self.n[to].child_index as f32 - self.n[from].child_index as f32;
528 if subtrees != 0.0 {
529 self.n[to].change -= shift / subtrees;
530 self.n[from].change += shift / subtrees;
531 }
532 self.n[to].shift += shift;
533 self.n[to].y += shift;
534 self.n[to].modifier += shift;
535 }
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541
542 fn nodes(pairs: &[(&str, Option<&str>)]) -> Vec<Node> {
543 pairs
544 .iter()
545 .map(|(id, parent)| Node { id: id.to_string(), parent: parent.map(str::to_string), icon: None, frame: "task".into(), title: String::new(), description: String::new(), obtained: false })
546 .collect()
547 }
548
549 fn at(l: &[(String, (f32, f32))], id: &str) -> (f32, f32) {
550 l.iter().find(|(i, _)| i == id).expect(id).1
551 }
552
553 #[test]
554 fn a_parent_is_centred_on_its_children() {
555 let l = layout(&nodes(&[("r", None), ("a", Some("r")), ("b", Some("r")), ("c", Some("r"))]));
556 assert_eq!(at(&l, "r"), (0.0, 1.0));
557 assert_eq!([at(&l, "a"), at(&l, "b"), at(&l, "c")], [(1.0, 0.0), (1.0, 1.0), (1.0, 2.0)]);
558 }
559
560 #[test]
561 fn subtrees_keep_apart() {
562 let l = layout(&nodes(&[("r", None), ("a", Some("r")), ("a1", Some("a")), ("a2", Some("a")), ("b", Some("r"))]));
563 assert_eq!(at(&l, "a1"), (2.0, 0.0));
564 assert_eq!(at(&l, "a2"), (2.0, 1.0));
565 assert_eq!(at(&l, "a"), (1.0, 0.5));
566 assert_eq!(at(&l, "b"), (1.0, 1.5));
567 assert_eq!(at(&l, "r"), (0.0, 1.0));
568 }
569
570 #[test]
571 fn a_cycle_is_still_shown() {
572 assert_eq!(layout(&nodes(&[("a", Some("b")), ("b", Some("a"))])).len(), 2);
573 }
574
575 #[test]
576 fn lines_break_at_words() {
577 let w = |s: &str| s.chars().count() as f32 * 6.0;
578 assert_eq!(split_lines("one two three", 48.0, &w), ["one two", "three"]);
579 assert_eq!(cut_to_width("abcdef", 18.0, &w), "abc");
580 }
581}
582