Skip to main content

yog_cli/design/
advancements.rs

1//! A tab of the game's advancements screen, drawn as the game draws it
2//! (`AdvancementsScreen`, `AdvancementTab`, `AdvancementWidget`,
3//! `TreeNodePosition` of 1.20.1–1.21.1): the tree laid out by the game's
4//! algorithm, the window, the root's background, the connectors, the frames
5//! and icons, and a hovered advancement's title bar and description — in
6//! the game's font, on a screen of [`SCREEN`] GUI pixels.
7
8use 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
17/// The screen the preview shows: 1280×720 at GUI scale 3.
18const SCREEN: (f32, f32) = (427.0, 240.0);
19/// The window, and its tab's view inside it.
20const WINDOW: (f32, f32) = (252.0, 140.0);
21const VIEW: Rect = Rect { x: 9.0, y: 18.0, w: 234.0, h: 113.0 };
22/// Where the tab's title is written.
23const TITLE_AT: Vec2 = Vec2 { x: 8.0, y: 6.0 };
24/// A tree position's step (column, row) and a widget's frame.
25const 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;
30/// A background tile's side.
31const TILE: f32 = 16.0;
32/// A connector: where it leaves a frame, how far it runs before turning.
33const CONNECT_MID: f32 = 13.0;
34const CONNECT_OUT: f32 = FRAME + 4.0;
35/// The hover: the bar's sprite width, the widest title, the text's place
36/// and line height, the room it leaves before the screen's edge.
37const 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;
46/// The description's first line from the top of a box that opens upward.
47const DESCRIPTION_TOP_UP: f32 = 7.0;
48const DESCRIPTION_PAD: f32 = 32.0;
49const FLIP_ROOM: f32 = 6.0;
50/// How far below or above the target width the game tries a description's
51/// line breaks, and how close is close enough.
52const SPLIT_OFFSETS: [f32; 5] = [0.0, 10.0, -10.0, 25.0, -25.0];
53const SPLIT_GOOD_ENOUGH: f32 = 10.0;
54/// What a hover dims the view with (`AdvancementTab`'s fade at its most).
55const HOVER_FADE: Color = Color(0x4C00_0000);
56/// The screen's dimmed world behind the window (`renderBackground`).
57const 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);
61/// How much bigger than GUI pixels the picture is drawn.
62const SCALE: u32 = 1;
63
64/// One advancement of the tab.
65#[derive(Deserialize, Debug, Clone)]
66pub struct Node {
67    pub id: String,
68    pub parent: Option<String>,
69    pub icon: Option<String>,
70    /// `task`, `goal` or `challenge`.
71    pub frame: String,
72    pub title: String,
73    pub description: String,
74    /// Drawn as had (the one being edited).
75    #[serde(default)]
76    pub obtained: bool,
77}
78
79/// What to draw.
80#[derive(Deserialize, Debug, Clone)]
81pub struct Args {
82    pub path: String,
83    pub title: String,
84    /// The root's background texture (`minecraft:textures/block/calcite.png` or `minecraft:block/calcite`).
85    pub background: Option<String>,
86    pub nodes: Vec<Node>,
87    /// How far the reader dragged the tree, GUI pixels.
88    #[serde(default)]
89    pub drag: [f32; 2],
90    /// The advancement under the pointer.
91    pub hover: Option<String>,
92}
93
94/// Where an advancement can be pointed at, in screen GUI pixels.
95#[derive(Serialize, Debug, Clone, PartialEq)]
96pub struct Spot {
97    pub id: String,
98    pub rect: [f32; 4],
99}
100
101/// The drawn screen.
102#[derive(Serialize, Debug, Clone)]
103pub struct Rendered {
104    pub image: Option<String>,
105    pub size: [f32; 2],
106    /// The view (where dragging scrolls), and each advancement's spot.
107    pub view: [f32; 4],
108    pub spots: Vec<Spot>,
109    /// The drag the tab accepts (clamped as the game clamps its scroll).
110    pub drag: [f32; 2],
111}
112
113/// Draws the tab.
114pub 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    // The tab's extent and its scroll (`AdvancementTab.addWidget`, `drawContents`, `scroll`).
122    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        // The world behind, dimmed (here: nothing behind).
139        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        // The view: the background tiled from the scroll, connectors, frames and icons (clipped by the window).
142        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        // The hover (`AdvancementTab.drawTooltips`, `AdvancementWidget.drawHover`).
178        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
197/// The window's sprite and a frame's (`yog-gfx/assets/sprites.ydata`).
198const 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
207/// The frame that's a challenge, and the chat colours `FrameType` writes descriptions in.
208const CHALLENGE: &str = "challenge";
209const DARK_PURPLE: char = '5';
210const GREEN: char = 'a';
211
212/// The colour a frame's description is written in (`FrameType`'s chat colour).
213fn 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
218/// `AdvancementWidget.drawConnectivity`: `GuiGraphics.hLine` / `vLine` rectangles.
219fn 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
240/// `AdvancementWidget.drawHover`, an advancement had or not (no progress between).
241fn 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    // Flipped left when it would run off the screen; the description above when below has no room.
252    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
277/// `Font.substrByWidth`: the longest start of `s` no wider than `max`.
278fn 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
286/// `AdvancementWidget.findOptimalLines`: the breaks whose widest line is
287/// nearest `target`, trying a few widths around it.
288fn 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
306/// `StringSplitter.splitLines`: words onto lines no wider than `max`
307/// (a word wider than a line is broken), `\n` breaking always.
308fn 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
337/// The part of `r` inside `within`.
338fn 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
344/// Each advancement's place: `(column, row)` — the game's `TreeNodePosition`
345/// (Walker's tidy tree), children in the order given; each root's tree
346/// under the one before.
347pub 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    // A cycle leaves some unplaced: a column of their own.
367    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
376/// `n`'s parent among `ids` (none: a root).
377fn 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