1use crate::Result;
2use crate::config::{
3 config_bool as cfg_bool, config_f64 as cfg_f64, config_f64_css_px, config_string as cfg_string,
4};
5use crate::model::{
6 Bounds, GitGraphArrowLayout, GitGraphBranchLayout, GitGraphCommitLayout, GitGraphDiagramLayout,
7};
8use crate::text::{TextMeasurer, TextStyle};
9use merman_core::diagrams::git_graph::{
10 GitGraphCommitRenderModel as GitGraphCommit, GitGraphRenderModel,
11};
12use std::collections::HashMap;
13
14const LAYOUT_OFFSET: f64 = 10.0;
15const COMMIT_STEP: f64 = 40.0;
16const DEFAULT_POS: f64 = 30.0;
17const THEME_COLOR_LIMIT: usize = 8;
18
19const COMMIT_TYPE_MERGE: i64 = 3;
20
21#[derive(Debug, Clone, Copy)]
22struct CommitPosition {
23 x: f64,
24 y: f64,
25}
26
27fn find_closest_parent<'a>(
28 parents: &'a [String],
29 dir: &str,
30 commit_pos: &HashMap<&str, CommitPosition>,
31) -> Option<&'a str> {
32 let mut target: f64 = if dir == "BT" { f64::INFINITY } else { 0.0 };
33 let mut closest: Option<&str> = None;
34 for parent in parents {
35 let Some(pos) = commit_pos.get(parent.as_str()) else {
36 continue;
37 };
38 let parent_position = if dir == "TB" || dir == "BT" {
39 pos.y
40 } else {
41 pos.x
42 };
43 if dir == "BT" {
44 if parent_position <= target {
45 closest = Some(parent.as_str());
46 target = parent_position;
47 }
48 } else if parent_position >= target {
49 closest = Some(parent.as_str());
50 target = parent_position;
51 }
52 }
53 closest
54}
55
56fn commit_axis_start_pos(dir: &str) -> f64 {
57 if dir == "TB" || dir == "BT" {
58 DEFAULT_POS
59 } else {
60 0.0
61 }
62}
63
64fn branch_label_bbox_width_px(
65 direction: &str,
66 text: &str,
67 style: &TextStyle,
68 measurer: &dyn TextMeasurer,
69) -> f64 {
70 if direction == "TB" || direction == "BT" {
71 let (left, right) = measurer.measure_svg_text_bbox_x(text, style);
77 crate::text::round_to_1_64_px_ties_to_even((left + right).max(0.0))
78 } else {
79 crate::text::round_to_1_64_px(
82 measurer
83 .measure_svg_text_computed_length_px(text, style)
84 .max(0.0),
85 )
86 }
87}
88
89fn should_reroute_arrow(
90 commit_a: &GitGraphCommit,
91 commit_b: &GitGraphCommit,
92 p1: CommitPosition,
93 p2: CommitPosition,
94 all_commits: &HashMap<&str, &GitGraphCommit>,
95 dir: &str,
96) -> bool {
97 let commit_b_is_furthest = if dir == "TB" || dir == "BT" {
98 p1.x < p2.x
99 } else {
100 p1.y < p2.y
101 };
102 let branch_to_get_curve = if commit_b_is_furthest {
103 commit_b.branch.as_str()
104 } else {
105 commit_a.branch.as_str()
106 };
107
108 all_commits.values().any(|commit_x| {
109 commit_x.branch == branch_to_get_curve
110 && commit_x.seq > commit_a.seq
111 && commit_x.seq < commit_b.seq
112 })
113}
114
115fn find_lane(y1: f64, y2: f64, lanes: &mut Vec<f64>, depth: usize) -> f64 {
116 let candidate = y1 + (y1 - y2).abs() / 2.0;
117 if depth > 5 {
118 return candidate;
119 }
120
121 let ok = lanes.iter().all(|lane| (lane - candidate).abs() >= 10.0);
122 if ok {
123 lanes.push(candidate);
124 return candidate;
125 }
126
127 let diff = (y1 - y2).abs();
128 find_lane(y1, y2 - diff / 5.0, lanes, depth + 1)
129}
130
131fn draw_arrow(
132 commit_a: &GitGraphCommit,
133 commit_b: &GitGraphCommit,
134 all_commits: &HashMap<&str, &GitGraphCommit>,
135 commit_pos: &HashMap<&str, CommitPosition>,
136 branch_index: &HashMap<&str, usize>,
137 lanes: &mut Vec<f64>,
138 dir: &str,
139) -> Option<GitGraphArrowLayout> {
140 let p1 = *commit_pos.get(commit_a.id.as_str())?;
141 let p2 = *commit_pos.get(commit_b.id.as_str())?;
142 let arrow_needs_rerouting = should_reroute_arrow(commit_a, commit_b, p1, p2, all_commits, dir);
143
144 let mut color_class_num = branch_index
145 .get(commit_b.branch.as_str())
146 .copied()
147 .unwrap_or(0);
148 if commit_b.commit_type == COMMIT_TYPE_MERGE
149 && commit_a
150 .id
151 .as_str()
152 .ne(commit_b.parents.first().map(|s| s.as_str()).unwrap_or(""))
153 {
154 color_class_num = branch_index
155 .get(commit_a.branch.as_str())
156 .copied()
157 .unwrap_or(color_class_num);
158 }
159
160 let mut line_def: Option<String> = None;
161 if arrow_needs_rerouting {
162 let arc = "A 10 10, 0, 0, 0,";
163 let arc2 = "A 10 10, 0, 0, 1,";
164 let radius = 10.0;
165 let offset = 10.0;
166
167 let line_y = if p1.y < p2.y {
168 find_lane(p1.y, p2.y, lanes, 0)
169 } else {
170 find_lane(p2.y, p1.y, lanes, 0)
171 };
172 let line_x = if p1.x < p2.x {
173 find_lane(p1.x, p2.x, lanes, 0)
174 } else {
175 find_lane(p2.x, p1.x, lanes, 0)
176 };
177
178 if dir == "TB" {
179 if p1.x < p2.x {
180 line_def = Some(format!(
181 "M {} {} L {} {} {} {} {} L {} {} {} {} {} L {} {}",
182 p1.x,
183 p1.y,
184 line_x - radius,
185 p1.y,
186 arc2,
187 line_x,
188 p1.y + offset,
189 line_x,
190 p2.y - radius,
191 arc,
192 line_x + offset,
193 p2.y,
194 p2.x,
195 p2.y
196 ));
197 } else {
198 color_class_num = branch_index
199 .get(commit_a.branch.as_str())
200 .copied()
201 .unwrap_or(0);
202 line_def = Some(format!(
203 "M {} {} L {} {} {} {} {} L {} {} {} {} {} L {} {}",
204 p1.x,
205 p1.y,
206 line_x + radius,
207 p1.y,
208 arc,
209 line_x,
210 p1.y + offset,
211 line_x,
212 p2.y - radius,
213 arc2,
214 line_x - offset,
215 p2.y,
216 p2.x,
217 p2.y
218 ));
219 }
220 } else if dir == "BT" {
221 if p1.x < p2.x {
222 line_def = Some(format!(
223 "M {} {} L {} {} {} {} {} L {} {} {} {} {} L {} {}",
224 p1.x,
225 p1.y,
226 line_x - radius,
227 p1.y,
228 arc,
229 line_x,
230 p1.y - offset,
231 line_x,
232 p2.y + radius,
233 arc2,
234 line_x + offset,
235 p2.y,
236 p2.x,
237 p2.y
238 ));
239 } else {
240 color_class_num = branch_index
241 .get(commit_a.branch.as_str())
242 .copied()
243 .unwrap_or(0);
244 line_def = Some(format!(
245 "M {} {} L {} {} {} {} {} L {} {} {} {} {} L {} {}",
246 p1.x,
247 p1.y,
248 line_x + radius,
249 p1.y,
250 arc2,
251 line_x,
252 p1.y - offset,
253 line_x,
254 p2.y + radius,
255 arc,
256 line_x - offset,
257 p2.y,
258 p2.x,
259 p2.y
260 ));
261 }
262 } else if p1.y < p2.y {
263 line_def = Some(format!(
264 "M {} {} L {} {} {} {} {} L {} {} {} {} {} L {} {}",
265 p1.x,
266 p1.y,
267 p1.x,
268 line_y - radius,
269 arc,
270 p1.x + offset,
271 line_y,
272 p2.x - radius,
273 line_y,
274 arc2,
275 p2.x,
276 line_y + offset,
277 p2.x,
278 p2.y
279 ));
280 } else {
281 color_class_num = branch_index
282 .get(commit_a.branch.as_str())
283 .copied()
284 .unwrap_or(0);
285 line_def = Some(format!(
286 "M {} {} L {} {} {} {} {} L {} {} {} {} {} L {} {}",
287 p1.x,
288 p1.y,
289 p1.x,
290 line_y + radius,
291 arc2,
292 p1.x + offset,
293 line_y,
294 p2.x - radius,
295 line_y,
296 arc,
297 p2.x,
298 line_y - offset,
299 p2.x,
300 p2.y
301 ));
302 }
303 } else {
304 let arc = "A 20 20, 0, 0, 0,";
305 let arc2 = "A 20 20, 0, 0, 1,";
306 let radius = 20.0;
307 let offset = 20.0;
308
309 if dir == "TB" {
310 if p1.x < p2.x {
311 if commit_b.commit_type == COMMIT_TYPE_MERGE
312 && commit_a.id.as_str().ne(commit_b
313 .parents
314 .first()
315 .map(|s| s.as_str())
316 .unwrap_or(""))
317 {
318 line_def = Some(format!(
319 "M {} {} L {} {} {} {} {} L {} {}",
320 p1.x,
321 p1.y,
322 p1.x,
323 p2.y - radius,
324 arc,
325 p1.x + offset,
326 p2.y,
327 p2.x,
328 p2.y
329 ));
330 } else {
331 line_def = Some(format!(
332 "M {} {} L {} {} {} {} {} L {} {}",
333 p1.x,
334 p1.y,
335 p2.x - radius,
336 p1.y,
337 arc2,
338 p2.x,
339 p1.y + offset,
340 p2.x,
341 p2.y
342 ));
343 }
344 }
345
346 if p1.x > p2.x {
347 if commit_b.commit_type == COMMIT_TYPE_MERGE
348 && commit_a.id.as_str().ne(commit_b
349 .parents
350 .first()
351 .map(|s| s.as_str())
352 .unwrap_or(""))
353 {
354 line_def = Some(format!(
355 "M {} {} L {} {} {} {} {} L {} {}",
356 p1.x,
357 p1.y,
358 p1.x,
359 p2.y - radius,
360 arc2,
361 p1.x - offset,
362 p2.y,
363 p2.x,
364 p2.y
365 ));
366 } else {
367 line_def = Some(format!(
368 "M {} {} L {} {} {} {} {} L {} {}",
369 p1.x,
370 p1.y,
371 p2.x + radius,
372 p1.y,
373 arc,
374 p2.x,
375 p1.y + offset,
376 p2.x,
377 p2.y
378 ));
379 }
380 }
381
382 if p1.x == p2.x {
383 line_def = Some(format!("M {} {} L {} {}", p1.x, p1.y, p2.x, p2.y));
384 }
385 } else if dir == "BT" {
386 if p1.x < p2.x {
387 if commit_b.commit_type == COMMIT_TYPE_MERGE
388 && commit_a.id.as_str().ne(commit_b
389 .parents
390 .first()
391 .map(|s| s.as_str())
392 .unwrap_or(""))
393 {
394 line_def = Some(format!(
395 "M {} {} L {} {} {} {} {} L {} {}",
396 p1.x,
397 p1.y,
398 p1.x,
399 p2.y + radius,
400 arc2,
401 p1.x + offset,
402 p2.y,
403 p2.x,
404 p2.y
405 ));
406 } else {
407 line_def = Some(format!(
408 "M {} {} L {} {} {} {} {} L {} {}",
409 p1.x,
410 p1.y,
411 p2.x - radius,
412 p1.y,
413 arc,
414 p2.x,
415 p1.y - offset,
416 p2.x,
417 p2.y
418 ));
419 }
420 }
421
422 if p1.x > p2.x {
423 if commit_b.commit_type == COMMIT_TYPE_MERGE
424 && commit_a.id.as_str().ne(commit_b
425 .parents
426 .first()
427 .map(|s| s.as_str())
428 .unwrap_or(""))
429 {
430 line_def = Some(format!(
431 "M {} {} L {} {} {} {} {} L {} {}",
432 p1.x,
433 p1.y,
434 p1.x,
435 p2.y + radius,
436 arc,
437 p1.x - offset,
438 p2.y,
439 p2.x,
440 p2.y
441 ));
442 } else {
443 line_def = Some(format!(
444 "M {} {} L {} {} {} {} {} L {} {}",
445 p1.x,
446 p1.y,
447 p2.x - radius,
448 p1.y,
449 arc,
450 p2.x,
451 p1.y - offset,
452 p2.x,
453 p2.y
454 ));
455 }
456 }
457
458 if p1.x == p2.x {
459 line_def = Some(format!("M {} {} L {} {}", p1.x, p1.y, p2.x, p2.y));
460 }
461 } else {
462 if p1.y < p2.y {
463 if commit_b.commit_type == COMMIT_TYPE_MERGE
464 && commit_a.id.as_str().ne(commit_b
465 .parents
466 .first()
467 .map(|s| s.as_str())
468 .unwrap_or(""))
469 {
470 line_def = Some(format!(
471 "M {} {} L {} {} {} {} {} L {} {}",
472 p1.x,
473 p1.y,
474 p2.x - radius,
475 p1.y,
476 arc2,
477 p2.x,
478 p1.y + offset,
479 p2.x,
480 p2.y
481 ));
482 } else {
483 line_def = Some(format!(
484 "M {} {} L {} {} {} {} {} L {} {}",
485 p1.x,
486 p1.y,
487 p1.x,
488 p2.y - radius,
489 arc,
490 p1.x + offset,
491 p2.y,
492 p2.x,
493 p2.y
494 ));
495 }
496 }
497
498 if p1.y > p2.y {
499 if commit_b.commit_type == COMMIT_TYPE_MERGE
500 && commit_a.id.as_str().ne(commit_b
501 .parents
502 .first()
503 .map(|s| s.as_str())
504 .unwrap_or(""))
505 {
506 line_def = Some(format!(
507 "M {} {} L {} {} {} {} {} L {} {}",
508 p1.x,
509 p1.y,
510 p2.x - radius,
511 p1.y,
512 arc,
513 p2.x,
514 p1.y - offset,
515 p2.x,
516 p2.y
517 ));
518 } else {
519 line_def = Some(format!(
520 "M {} {} L {} {} {} {} {} L {} {}",
521 p1.x,
522 p1.y,
523 p1.x,
524 p2.y + radius,
525 arc2,
526 p1.x + offset,
527 p2.y,
528 p2.x,
529 p2.y
530 ));
531 }
532 }
533
534 if p1.y == p2.y {
535 line_def = Some(format!("M {} {} L {} {}", p1.x, p1.y, p2.x, p2.y));
536 }
537 }
538 }
539
540 let d = line_def?;
541 Some(GitGraphArrowLayout {
542 from: commit_a.id.clone(),
543 to: commit_b.id.clone(),
544 class_index: (color_class_num % THEME_COLOR_LIMIT) as i64,
545 d,
546 })
547}
548
549pub fn layout_gitgraph_diagram(
550 semantic: &serde_json::Value,
551 effective_config: &serde_json::Value,
552 measurer: &dyn TextMeasurer,
553) -> Result<GitGraphDiagramLayout> {
554 let model: GitGraphRenderModel = crate::json::from_value_ref(semantic)?;
555 layout_gitgraph_diagram_typed(&model, effective_config, measurer)
556}
557
558pub fn layout_gitgraph_diagram_typed(
559 model: &GitGraphRenderModel,
560 effective_config: &serde_json::Value,
561 measurer: &dyn TextMeasurer,
562) -> Result<GitGraphDiagramLayout> {
563 let _ = model.diagram_type.as_str();
564
565 let direction = if model.direction.trim().is_empty() {
566 "LR".to_string()
567 } else {
568 model.direction.trim().to_string()
569 };
570
571 let rotate_commit_label =
572 cfg_bool(effective_config, &["gitGraph", "rotateCommitLabel"]).unwrap_or(true);
573 let show_commit_label =
574 cfg_bool(effective_config, &["gitGraph", "showCommitLabel"]).unwrap_or(true);
575 let show_branches = cfg_bool(effective_config, &["gitGraph", "showBranches"]).unwrap_or(true);
576 let diagram_padding = cfg_f64(effective_config, &["gitGraph", "diagramPadding"])
577 .unwrap_or(8.0)
578 .max(0.0);
579 let parallel_commits =
580 cfg_bool(effective_config, &["gitGraph", "parallelCommits"]).unwrap_or(false);
581
582 let font_family = cfg_string(effective_config, &["fontFamily"])
585 .or_else(|| cfg_string(effective_config, &["themeVariables", "fontFamily"]))
586 .map(|s| s.trim().trim_end_matches(';').trim().to_string())
587 .filter(|s| !s.is_empty())
588 .unwrap_or_else(|| "\"trebuchet ms\", verdana, arial, sans-serif".to_string());
589 let font_size = config_f64_css_px(effective_config, &["themeVariables", "fontSize"])
590 .unwrap_or(16.0)
591 .max(1.0);
592
593 let label_style = TextStyle {
594 font_family: Some(font_family),
595 font_size,
596 font_weight: None,
597 };
598
599 let mut branches: Vec<GitGraphBranchLayout> = Vec::new();
600 let mut branch_pos: HashMap<&str, f64> = HashMap::new();
601 let mut branch_index: HashMap<&str, usize> = HashMap::new();
602 let mut pos = 0.0;
603 for (i, b) in model.branches.iter().enumerate() {
604 let metrics = measurer.measure(&b.name, &label_style);
605 let bbox_w = branch_label_bbox_width_px(&direction, &b.name, &label_style, measurer);
606 branch_pos.insert(b.name.as_str(), pos);
607 branch_index.insert(b.name.as_str(), i);
608
609 branches.push(GitGraphBranchLayout {
610 name: b.name.clone(),
611 index: i as i64,
612 pos,
613 bbox_width: bbox_w.max(0.0),
614 bbox_height: metrics.height.max(0.0),
615 });
616
617 pos += 50.0
618 + if rotate_commit_label { 40.0 } else { 0.0 }
619 + if direction == "TB" || direction == "BT" {
620 bbox_w.max(0.0) / 2.0
621 } else {
622 0.0
623 };
624 }
625
626 let commits_by_id: HashMap<&str, &GitGraphCommit> =
627 model.commits.iter().map(|c| (c.id.as_str(), c)).collect();
628
629 let mut commit_order: Vec<&GitGraphCommit> = model.commits.iter().collect();
630 commit_order.sort_by_key(|c| c.seq);
631
632 let mut sorted_keys: Vec<&str> = commit_order.iter().map(|c| c.id.as_str()).collect();
633 let mirror_parallel_bt_axis = direction == "BT" && parallel_commits;
634 if direction == "BT" && !mirror_parallel_bt_axis {
635 sorted_keys.reverse();
636 }
637
638 let mut commit_pos: HashMap<&str, CommitPosition> = HashMap::new();
639 let mut commits: Vec<GitGraphCommitLayout> = Vec::new();
640 let mut max_pos: f64 = 0.0;
641 let mut cur_pos = commit_axis_start_pos(&direction);
642
643 for &id in &sorted_keys {
644 let Some(commit) = commits_by_id.get(id).copied() else {
645 continue;
646 };
647
648 if parallel_commits {
649 if !commit.parents.is_empty() {
650 if let Some(closest_parent) =
651 find_closest_parent(&commit.parents, &direction, &commit_pos)
652 && let Some(parent_position) = commit_pos.get(closest_parent)
653 {
654 if mirror_parallel_bt_axis {
655 cur_pos = parent_position.y + COMMIT_STEP + LAYOUT_OFFSET;
656 } else if direction == "TB" {
657 cur_pos = parent_position.y + COMMIT_STEP;
658 } else if direction == "BT" {
659 let current_position = commit_pos
660 .get(commit.id.as_str())
661 .copied()
662 .unwrap_or(CommitPosition { x: 0.0, y: 0.0 });
663 cur_pos = current_position.y - COMMIT_STEP;
664 } else {
665 cur_pos = parent_position.x + COMMIT_STEP;
666 }
667 }
668 } else {
669 cur_pos = commit_axis_start_pos(&direction);
670 }
671 }
672
673 let pos_with_offset = if direction == "BT" && parallel_commits {
674 cur_pos
675 } else {
676 cur_pos + LAYOUT_OFFSET
677 };
678 let Some(branch_lane) = branch_pos.get(commit.branch.as_str()).copied() else {
679 return Err(crate::Error::InvalidModel {
680 message: format!("unknown branch for commit {}: {}", commit.id, commit.branch),
681 });
682 };
683
684 let (x, y) = if direction == "TB" || direction == "BT" {
685 (branch_lane, pos_with_offset)
686 } else {
687 (pos_with_offset, branch_lane)
688 };
689 commit_pos.insert(commit.id.as_str(), CommitPosition { x, y });
690
691 commits.push(GitGraphCommitLayout {
692 id: commit.id.clone(),
693 message: commit.message.clone(),
694 seq: commit.seq,
695 commit_type: commit.commit_type,
696 custom_type: commit.custom_type,
697 custom_id: commit.custom_id,
698 tags: commit.tags.clone(),
699 parents: commit.parents.clone(),
700 branch: commit.branch.clone(),
701 pos: cur_pos,
702 pos_with_offset,
703 x,
704 y,
705 });
706
707 cur_pos += COMMIT_STEP + LAYOUT_OFFSET;
708 max_pos = max_pos.max(cur_pos);
709 }
710
711 if mirror_parallel_bt_axis && !commits.is_empty() {
712 let mirror_axis = max_pos - DEFAULT_POS;
716 max_pos -= 2.0 * LAYOUT_OFFSET;
717
718 for commit in &mut commits {
719 let y = mirror_axis - commit.y;
720 commit.pos = y;
721 commit.pos_with_offset = y;
722 commit.y = y;
723 }
724
725 for position in commit_pos.values_mut() {
726 position.y = mirror_axis - position.y;
727 }
728 }
729
730 let mut lanes: Vec<f64> = if show_branches {
731 branches.iter().map(|b| b.pos).collect()
732 } else {
733 Vec::new()
734 };
735
736 let mut arrows: Vec<GitGraphArrowLayout> = Vec::new();
737 for commit_b in commit_order {
740 for parent in &commit_b.parents {
741 let Some(commit_a) = commits_by_id.get(parent.as_str()).copied() else {
742 continue;
743 };
744 if let Some(a) = draw_arrow(
745 commit_a,
746 commit_b,
747 &commits_by_id,
748 &commit_pos,
749 &branch_index,
750 &mut lanes,
751 &direction,
752 ) {
753 arrows.push(a);
754 }
755 }
756 }
757
758 let mut min_x = f64::INFINITY;
759 let mut min_y = f64::INFINITY;
760 let mut max_x = f64::NEG_INFINITY;
761 let mut max_y = f64::NEG_INFINITY;
762
763 for b in &branches {
764 if direction == "TB" || direction == "BT" {
765 min_x = min_x.min(b.pos);
766 max_x = max_x.max(b.pos);
767 min_y = min_y.min(DEFAULT_POS.min(max_pos));
768 max_y = max_y.max(DEFAULT_POS.max(max_pos));
769 } else {
770 min_y = min_y.min(b.pos);
771 max_y = max_y.max(b.pos);
772 min_x = min_x.min(0.0);
773 max_x = max_x.max(max_pos);
774 let label_left =
775 -b.bbox_width - 4.0 - if rotate_commit_label { 30.0 } else { 0.0 } - 19.0;
776 min_x = min_x.min(label_left);
777 }
778 }
779
780 for c in &commits {
781 let r = if c.custom_type.unwrap_or(c.commit_type) == COMMIT_TYPE_MERGE {
782 9.0
783 } else {
784 10.0
785 };
786 min_x = min_x.min(c.x - r);
787 min_y = min_y.min(c.y - r);
788 max_x = max_x.max(c.x + r);
789 max_y = max_y.max(c.y + r);
790 }
791
792 let bounds = if min_x.is_finite() && min_y.is_finite() && max_x.is_finite() && max_y.is_finite()
793 {
794 Some(Bounds {
795 min_x: min_x - diagram_padding,
796 min_y: min_y - diagram_padding,
797 max_x: max_x + diagram_padding,
798 max_y: max_y + diagram_padding,
799 })
800 } else {
801 None
802 };
803
804 Ok(GitGraphDiagramLayout {
805 bounds,
806 direction,
807 rotate_commit_label,
808 show_branches,
809 show_commit_label,
810 parallel_commits,
811 diagram_padding,
812 max_pos,
813 branches,
814 commits,
815 arrows,
816 })
817}
818
819#[cfg(test)]
820mod tests {
821 use super::*;
822 use crate::text::VendoredFontMetricsTextMeasurer;
823 use merman_core::diagrams::git_graph::{
824 GitGraphBranchRenderModel, GitGraphCommitRenderModel, GitGraphRenderModel,
825 };
826 use serde_json::json;
827
828 fn commit(id: &str, seq: i64, parents: &[&str], branch: &str) -> GitGraphCommitRenderModel {
829 GitGraphCommitRenderModel {
830 id: id.to_string(),
831 message: id.to_string(),
832 seq,
833 commit_type: 0,
834 tags: Vec::new(),
835 parents: parents.iter().map(|p| (*p).to_string()).collect(),
836 branch: branch.to_string(),
837 custom_type: None,
838 custom_id: Some(true),
839 }
840 }
841
842 #[test]
843 fn font_size_ignores_top_level_font_size() {
844 let cfg = json!({
845 "fontSize": 22,
846 "themeVariables": {
847 "fontFamily": "\"courier new\", courier, monospace;",
848 },
849 });
850
851 assert_eq!(
852 config_f64_css_px(&cfg, &["themeVariables", "fontSize"])
853 .unwrap_or(16.0)
854 .max(1.0),
855 16.0
856 );
857 }
858
859 #[test]
860 fn font_size_honors_theme_variable_font_size() {
861 let cfg = json!({
862 "fontSize": 10,
863 "themeVariables": {
864 "fontSize": "24px",
865 },
866 });
867
868 assert_eq!(
869 config_f64_css_px(&cfg, &["themeVariables", "fontSize"])
870 .unwrap_or(16.0)
871 .max(1.0),
872 24.0
873 );
874 }
875
876 #[test]
877 fn vertical_branch_label_widths_use_centered_bbox_ties_to_even() {
878 let measurer = VendoredFontMetricsTextMeasurer::default();
879 let style = TextStyle {
880 font_family: Some("\"trebuchet ms\", verdana, arial, sans-serif".to_string()),
881 font_size: 16.0,
882 font_weight: None,
883 };
884
885 assert_eq!(
886 branch_label_bbox_width_px("TB", "main", &style, &measurer),
887 35.0
888 );
889 assert_eq!(
890 branch_label_bbox_width_px("TB", "branch1", &style, &measurer),
891 57.34375
892 );
893 assert_eq!(
894 branch_label_bbox_width_px("TB", "branch4", &style, &measurer),
895 57.34375
896 );
897 assert_eq!(
898 branch_label_bbox_width_px("LR", "branch4", &style, &measurer),
899 57.359375
900 );
901 }
902
903 #[test]
904 fn parallel_lr_unconnected_branches_restart_commit_axis() {
905 let model = GitGraphRenderModel {
906 diagram_type: "gitGraph".to_string(),
907 branches: ["main", "dev", "v2", "feat"]
908 .into_iter()
909 .map(|name| GitGraphBranchRenderModel {
910 name: name.to_string(),
911 })
912 .collect(),
913 commits: vec![
914 commit("1-abcdefg", 0, &[], "feat"),
915 commit("2-abcdefg", 1, &["1-abcdefg"], "feat"),
916 commit("3-abcdefg", 2, &[], "main"),
917 commit("4-abcdefg", 3, &[], "dev"),
918 commit("5-abcdefg", 4, &[], "v2"),
919 commit("6-abcdefg", 5, &["3-abcdefg"], "main"),
920 ],
921 current_branch: "main".to_string(),
922 direction: "LR".to_string(),
923 acc_title: None,
924 acc_descr: None,
925 warnings: Vec::new(),
926 };
927 let cfg = json!({ "gitGraph": { "parallelCommits": true } });
928 let measurer = VendoredFontMetricsTextMeasurer::default();
929 let layout = layout_gitgraph_diagram_typed(&model, &cfg, &measurer).unwrap();
930
931 let x_by_id = layout
932 .commits
933 .iter()
934 .map(|c| (c.id.as_str(), c.x))
935 .collect::<HashMap<_, _>>();
936
937 assert_eq!(x_by_id["1-abcdefg"], 10.0);
938 assert_eq!(x_by_id["2-abcdefg"], 60.0);
939 assert_eq!(x_by_id["3-abcdefg"], 10.0);
940 assert_eq!(x_by_id["4-abcdefg"], 10.0);
941 assert_eq!(x_by_id["5-abcdefg"], 10.0);
942 assert_eq!(x_by_id["6-abcdefg"], 60.0);
943 assert_eq!(layout.max_pos, 100.0);
944 }
945
946 #[test]
947 fn parallel_bt_commits_use_mirrored_compact_axis() {
948 let model = GitGraphRenderModel {
949 diagram_type: "gitGraph".to_string(),
950 branches: ["main", "develop", "feature"]
951 .into_iter()
952 .map(|name| GitGraphBranchRenderModel {
953 name: name.to_string(),
954 })
955 .collect(),
956 commits: vec![
957 commit("1-abcdefg", 0, &[], "main"),
958 commit("2-abcdefg", 1, &["1-abcdefg"], "main"),
959 commit("3-abcdefg", 2, &["2-abcdefg"], "develop"),
960 commit("4-abcdefg", 3, &["3-abcdefg"], "develop"),
961 commit("5-abcdefg", 4, &["2-abcdefg"], "feature"),
962 commit("6-abcdefg", 5, &["5-abcdefg"], "feature"),
963 commit("7-abcdefg", 6, &["2-abcdefg"], "main"),
964 commit("8-abcdefg", 7, &["7-abcdefg"], "main"),
965 ],
966 current_branch: "main".to_string(),
967 direction: "BT".to_string(),
968 acc_title: None,
969 acc_descr: None,
970 warnings: Vec::new(),
971 };
972 let cfg = json!({ "gitGraph": { "parallelCommits": true } });
973 let measurer = VendoredFontMetricsTextMeasurer::default();
974 let layout = layout_gitgraph_diagram_typed(&model, &cfg, &measurer).unwrap();
975
976 let y_by_id = layout
977 .commits
978 .iter()
979 .map(|c| (c.id.as_str(), c.y))
980 .collect::<HashMap<_, _>>();
981
982 assert_eq!(y_by_id["1-abcdefg"], 170.0);
983 assert_eq!(y_by_id["2-abcdefg"], 120.0);
984 assert_eq!(y_by_id["3-abcdefg"], 70.0);
985 assert_eq!(y_by_id["4-abcdefg"], 20.0);
986 assert_eq!(y_by_id["5-abcdefg"], 70.0);
987 assert_eq!(y_by_id["6-abcdefg"], 20.0);
988 assert_eq!(y_by_id["7-abcdefg"], 70.0);
989 assert_eq!(y_by_id["8-abcdefg"], 20.0);
990 assert_eq!(layout.max_pos, 210.0);
991 }
992}