gitfetch_rs/display/
formatter.rs

1use super::graph::ContributionGraph;
2use crate::config::Config;
3use anyhow::Result;
4use serde_json::Value;
5
6enum Layout {
7  Minimal,
8  Compact,
9  Full,
10}
11
12#[derive(Debug, Clone, Default)]
13pub struct VisualOptions {
14  pub graph_only: bool,
15  pub spaced: bool,
16  pub graph_timeline: bool,
17  pub width: Option<usize>,
18  pub height: Option<usize>,
19  pub no_achievements: bool,
20  pub no_languages: bool,
21  pub no_issues: bool,
22  pub no_pr: bool,
23  pub no_account: bool,
24  pub no_grid: bool,
25}
26
27pub struct DisplayFormatter {
28  config: Config,
29  terminal_width: usize,
30  terminal_height: usize,
31  visual_opts: VisualOptions,
32}
33
34impl DisplayFormatter {
35  pub fn new(config: Config, visual_opts: VisualOptions) -> Result<Self> {
36    let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
37
38    Ok(Self {
39      config,
40      terminal_width: cols as usize,
41      terminal_height: rows as usize,
42      visual_opts,
43    })
44  }
45
46  pub fn display(&self, username: &str, user_data: &Value, stats: &Value) -> Result<()> {
47    // Handle --graph-timeline option
48    if self.visual_opts.graph_timeline {
49      let timeline = crate::utils::timeline::get_git_timeline_graph(false)?;
50      println!();
51      println!("{}", timeline);
52      return Ok(());
53    }
54
55    // Handle --graph-only option
56    if self.visual_opts.graph_only {
57      self.display_contribution_graph(username, stats)?;
58      println!();
59      return Ok(());
60    }
61
62    let layout = self.determine_layout(username, user_data, stats);
63
64    match layout {
65      Layout::Minimal => self.display_minimal(username, stats)?,
66      Layout::Compact => self.display_compact(username, user_data, stats)?,
67      Layout::Full => self.display_full(username, user_data, stats)?,
68    }
69
70    println!();
71    Ok(())
72  }
73
74  fn determine_layout(&self, username: &str, user_data: &Value, stats: &Value) -> Layout {
75    // Try layouts in order: full -> compact -> minimal
76    // Choose the first one that fits in terminal dimensions
77    let layouts = vec![Layout::Full, Layout::Compact, Layout::Minimal];
78
79    for layout in layouts {
80      let (width, height) = self.calculate_layout_dimensions(username, user_data, stats, &layout);
81      let available_height = self.terminal_height.saturating_sub(2).max(10);
82
83      if width <= self.terminal_width && height <= available_height {
84        return layout;
85      }
86    }
87
88    Layout::Minimal
89  }
90
91  fn calculate_layout_dimensions(
92    &self,
93    username: &str,
94    user_data: &Value,
95    stats: &Value,
96    layout: &Layout,
97  ) -> (usize, usize) {
98    match layout {
99      Layout::Minimal => self.calculate_minimal_dimensions(username, stats),
100      Layout::Compact => self.calculate_compact_dimensions(username, user_data, stats),
101      Layout::Full => self.calculate_full_dimensions(username, user_data, stats),
102    }
103  }
104
105  fn calculate_minimal_dimensions(&self, _username: &str, _stats: &Value) -> (usize, usize) {
106    if !self.visual_opts.no_grid {
107      let width = self
108        .visual_opts
109        .width
110        .unwrap_or(self.terminal_width.saturating_sub(4));
111      let height = self.visual_opts.height.unwrap_or(7);
112      (width, height)
113    } else {
114      // Just header
115      (50, 2)
116    }
117  }
118
119  fn calculate_compact_dimensions(
120    &self,
121    _username: &str,
122    _user_data: &Value,
123    _stats: &Value,
124  ) -> (usize, usize) {
125    let graph_width = self
126      .visual_opts
127      .width
128      .unwrap_or_else(|| (self.terminal_width.saturating_sub(40).max(40) * 3) / 4);
129
130    let graph_height = if !self.visual_opts.no_grid {
131      self.visual_opts.height.unwrap_or(7)
132    } else {
133      2
134    };
135
136    let mut right_lines = 0;
137    if !self.visual_opts.no_account {
138      right_lines += 1; // User info header
139    }
140    if !self.visual_opts.no_achievements {
141      right_lines += 5; // Achievements section
142    }
143
144    let max_lines = graph_height.max(right_lines);
145    let right_width = 40; // Estimated right side width
146
147    (graph_width + 2 + right_width, max_lines)
148  }
149
150  fn calculate_full_dimensions(
151    &self,
152    _username: &str,
153    _user_data: &Value,
154    stats: &Value,
155  ) -> (usize, usize) {
156    let graph_width = self
157      .visual_opts
158      .width
159      .unwrap_or_else(|| ((self.terminal_width.saturating_sub(10).max(50) * 3) / 4).max(50));
160
161    let graph_height = if !self.visual_opts.no_grid {
162      let base_height = self.visual_opts.height.unwrap_or(7);
163      let mut total = base_height + 1; // +1 for month line
164
165      // Add PR/Issues sections if enabled
166      if !self.visual_opts.no_pr || !self.visual_opts.no_issues {
167        total += 6; // Estimated PR/Issues section height
168      }
169      total
170    } else {
171      2
172    };
173
174    let mut right_height = 0;
175    if !self.visual_opts.no_account {
176      right_height += 6; // User info
177    }
178    if !self.visual_opts.no_languages && self.terminal_width >= 120 {
179      if let Some(langs) = stats["languages"].as_object() {
180        right_height += 2 + langs.len().min(5); // Languages section
181      }
182    }
183    if !self.visual_opts.no_achievements {
184      right_height += 5; // Achievements
185    }
186
187    let max_height = graph_height.max(right_height);
188    let right_width = 45; // Estimated right side width
189
190    (graph_width + 2 + right_width, max_height)
191  }
192
193  fn display_minimal(&self, username: &str, stats: &Value) -> Result<()> {
194    println!();
195    self.display_contribution_graph(username, stats)?;
196    Ok(())
197  }
198
199  fn display_compact(&self, username: &str, user_data: &Value, stats: &Value) -> Result<()> {
200    println!();
201
202    let graph = ContributionGraph::from_json(&stats["contribution_graph"]);
203    let graph_width = (self.terminal_width.saturating_sub(40).max(40) * 3) / 4;
204
205    // Left side: graph lines
206    let graph_lines = if !self.visual_opts.no_grid {
207      self.get_contribution_graph_lines(username, stats)?
208    } else {
209      let total_contribs = graph.calculate_total_contributions();
210      let name = user_data["name"].as_str().unwrap_or(username);
211      vec![format!(
212        "\x1b[38;2;118;215;161m{}\x1b[0m - \x1b[38;2;255;184;108m{}\x1b[0m \x1b[38;2;118;215;161mcontributions this year\x1b[0m",
213        name, total_contribs
214      )]
215    };
216
217    // Right side: compact user info + achievements (NO languages, NO PR/Issues)
218    let mut right_lines = Vec::new();
219
220    if !self.visual_opts.no_account {
221      let total_contribs = graph.calculate_total_contributions();
222      let name = user_data["name"].as_str().unwrap_or(username);
223      right_lines.push(format!(
224        "\x1b[38;2;118;215;161m{}\x1b[0m - \x1b[38;2;255;184;108m{}\x1b[0m \x1b[38;2;118;215;161mcontributions this year\x1b[0m",
225        name, total_contribs
226      ));
227    }
228
229    if !self.visual_opts.no_achievements {
230      let achievement_lines = self.format_achievements(&graph);
231      if !achievement_lines.is_empty() {
232        if !right_lines.is_empty() {
233          right_lines.push(String::new());
234        }
235        right_lines.extend(achievement_lines);
236      }
237    }
238
239    // Display side-by-side
240    let max_lines = graph_lines.len().max(right_lines.len());
241    for i in 0..max_lines {
242      let graph_part = if i < graph_lines.len() {
243        &graph_lines[i]
244      } else {
245        ""
246      };
247      let graph_len = self.display_width(graph_part);
248      let padding = " ".repeat(graph_width.saturating_sub(graph_len));
249
250      let info_part = if i < right_lines.len() {
251        &right_lines[i]
252      } else {
253        ""
254      };
255
256      println!("{}{}  {}", graph_part, padding, info_part);
257    }
258
259    Ok(())
260  }
261
262  fn display_full(&self, username: &str, user_data: &Value, stats: &Value) -> Result<()> {
263    println!();
264
265    let graph = ContributionGraph::from_json(&stats["contribution_graph"]);
266    let total_contribs = graph.calculate_total_contributions();
267
268    // Left: contribution graph + PR/Issues below (only if --no-grid is not set)
269    let mut graph_lines = if !self.visual_opts.no_grid {
270      self.get_contribution_graph_lines(username, stats)?
271    } else {
272      vec![]
273    };
274
275    // Add PR/Issues sections to left side (below graph) if enabled
276    if !self.visual_opts.no_pr || !self.visual_opts.no_issues {
277      let pr_lines = if !self.visual_opts.no_pr {
278        self.format_pull_requests(stats)
279      } else {
280        vec![]
281      };
282
283      let issue_lines = if !self.visual_opts.no_issues {
284        self.format_issues(stats)
285      } else {
286        vec![]
287      };
288
289      // Combine PR and Issues side-by-side below graph
290      if !pr_lines.is_empty() || !issue_lines.is_empty() {
291        if !graph_lines.is_empty() {
292          graph_lines.push(String::new()); // Add spacing
293        }
294
295        let pr_width = pr_lines
296          .iter()
297          .map(|l| self.display_width(l))
298          .max()
299          .unwrap_or(0);
300        let max_section_lines = pr_lines.len().max(issue_lines.len());
301
302        for i in 0..max_section_lines {
303          let pr_part = if i < pr_lines.len() { &pr_lines[i] } else { "" };
304          let pr_part_width = self.display_width(pr_part);
305          let padding = " ".repeat(pr_width.saturating_sub(pr_part_width) + 3);
306
307          let issue_part = if i < issue_lines.len() {
308            &issue_lines[i]
309          } else {
310            ""
311          };
312
313          // Add 4-space indentation to match graph
314          graph_lines.push(format!("    {}{}{}", pr_part, padding, issue_part));
315        }
316      }
317    }
318
319    // Right: user information
320    let mut right_lines = vec![];
321
322    if !self.visual_opts.no_account {
323      right_lines.extend(self.format_user_info(username, user_data, stats, total_contribs));
324    }
325
326    if !self.visual_opts.no_languages {
327      let language_lines = self.format_languages(stats);
328      if !language_lines.is_empty() {
329        if !right_lines.is_empty() {
330          right_lines.push(String::new());
331        }
332        right_lines.extend(language_lines);
333      }
334    }
335
336    if !self.visual_opts.no_achievements {
337      let achievement_lines = self.format_achievements(&graph);
338      if !achievement_lines.is_empty() {
339        if !right_lines.is_empty() {
340          right_lines.push(String::new());
341        }
342        right_lines.extend(achievement_lines);
343      }
344    }
345
346    // Side-by-side output
347    let max_left_width = graph_lines
348      .iter()
349      .map(|l| self.display_width(l))
350      .max()
351      .unwrap_or(0);
352
353    let max_lines = graph_lines.len().max(right_lines.len());
354
355    for i in 0..max_lines {
356      let left = if i < graph_lines.len() {
357        &graph_lines[i]
358      } else {
359        ""
360      };
361      let left_width = self.display_width(left);
362      let padding = " ".repeat(max_left_width.saturating_sub(left_width));
363
364      let right = if i < right_lines.len() {
365        &right_lines[i]
366      } else {
367        ""
368      };
369
370      println!("{}{}  {}", left, padding, right);
371    }
372
373    Ok(())
374  }
375
376  fn get_contribution_graph_lines(&self, _username: &str, stats: &Value) -> Result<Vec<String>> {
377    let graph = ContributionGraph::from_json(&stats["contribution_graph"]);
378    let custom_box = self.config.custom_box.as_deref().unwrap_or("■");
379    let show_date = true; // Always show month labels
380    let spaced = self.visual_opts.spaced;
381
382    // Use width/height options if specified, otherwise None (defaults: width=52, height=7)
383    let lines = graph.render(
384      self.visual_opts.width,
385      self.visual_opts.height,
386      custom_box,
387      &self.config.colors,
388      show_date,
389      spaced,
390    );
391
392    Ok(lines)
393  }
394
395  fn display_contribution_graph(&self, username: &str, stats: &Value) -> Result<()> {
396    let lines = self.get_contribution_graph_lines(username, stats)?;
397    for line in lines {
398      println!("{}", line);
399    }
400    Ok(())
401  }
402
403  pub fn display_simulation_from_grid(&self, grid: Vec<Vec<u8>>) -> Result<()> {
404    let graph = ContributionGraph::from_grid(grid);
405    let custom_box = self.config.custom_box.as_deref().unwrap_or("■");
406    let show_date = false; // No date labels for simulations
407    let spaced = self.visual_opts.spaced;
408
409    let lines = graph.render(
410      None, // Use full width
411      None, // Use full height (7 days)
412      custom_box,
413      &self.config.colors,
414      show_date,
415      spaced,
416    );
417
418    for line in lines {
419      println!("{}", line);
420    }
421
422    Ok(())
423  }
424
425  fn format_user_info(
426    &self,
427    _username: &str,
428    user_data: &Value,
429    stats: &Value,
430    total_contribs: u32,
431  ) -> Vec<String> {
432    let mut lines = Vec::new();
433
434    let name = user_data["name"].as_str().unwrap_or("Unknown");
435    let header = format!(
436      "\x1b[38;2;118;215;161m{}\x1b[0m - \x1b[38;2;255;184;108m{}\x1b[0m \x1b[38;2;118;215;161mcontributions this year\x1b[0m",
437      name, total_contribs
438    );
439    lines.push(header);
440
441    let plain = format!("{} - {} contributions this year", name, total_contribs);
442    lines.push(self.colorize(&"─".repeat(plain.len()), "muted"));
443
444    if let Some(bio) = user_data["bio"].as_str() {
445      if !bio.is_empty() {
446        let trimmed = bio.replace('\n', " ");
447        let truncated = if trimmed.len() > 80 {
448          &trimmed[..80]
449        } else {
450          &trimmed
451        };
452        lines.push(format!("{} {}", self.label("Bio"), truncated));
453      }
454    }
455
456    if let Some(company) = user_data["company"].as_str() {
457      if !company.is_empty() {
458        lines.push(format!("{} {}", self.label("Company"), company));
459      }
460    }
461
462    if let Some(blog) = user_data["blog"].as_str() {
463      if !blog.is_empty() {
464        lines.push(format!("{} {}", self.label("Website"), blog));
465      }
466    }
467
468    // Add stars amount
469    if let Some(total_stars) = stats["total_stars"].as_i64() {
470      lines.push(format!("{} {} ⭐", self.label("Stars"), total_stars));
471    }
472
473    lines
474  }
475
476  fn format_languages(&self, stats: &Value) -> Vec<String> {
477    let mut lines = Vec::new();
478
479    let languages = match stats["languages"].as_object() {
480      Some(langs) if !langs.is_empty() => langs,
481      _ => return lines,
482    };
483
484    lines.push(self.colorize("TOP LANGUAGES", "header"));
485    lines.push(self.colorize(&"─".repeat(13), "muted"));
486
487    let mut lang_vec: Vec<_> = languages.iter().collect();
488    lang_vec.sort_by(|a, b| {
489      let a_val = a.1.as_f64().unwrap_or(0.0);
490      let b_val = b.1.as_f64().unwrap_or(0.0);
491      b_val.partial_cmp(&a_val).unwrap()
492    });
493
494    for (lang, percentage) in lang_vec.iter().take(5) {
495      let pct = percentage.as_f64().unwrap_or(0.0);
496      let lang_name = if lang.to_lowercase() == "jupyter notebook" {
497        "Jupyter"
498      } else {
499        lang
500      };
501
502      let bar = self.render_progress_bar(pct, 24);
503      lines.push(format!("{} {} {:5.1}%", self.label(lang_name), bar, pct));
504    }
505
506    lines
507  }
508
509  fn format_achievements(&self, graph: &ContributionGraph) -> Vec<String> {
510    let mut lines = Vec::new();
511
512    let (current_streak, max_streak) = graph.calculate_streaks();
513    let total_contribs = graph.calculate_total_contributions();
514
515    lines.push(self.colorize("ACHIEVEMENTS", "header"));
516    lines.push(self.colorize(&"─".repeat(12), "muted"));
517
518    if current_streak > 0 {
519      let streak_text = if current_streak == 1 {
520        "day".to_string()
521      } else {
522        "days".to_string()
523      };
524      lines.push(format!(
525        "{} Current Streak  {} {}",
526        self.colorize("🔥", "red"),
527        current_streak,
528        streak_text
529      ));
530    }
531
532    if max_streak > 0 {
533      let streak_text = if max_streak == 1 {
534        "day".to_string()
535      } else {
536        "days".to_string()
537      };
538      lines.push(format!(
539        "{} Best Streak     {} {}",
540        self.colorize("⭐", "yellow"),
541        max_streak,
542        streak_text
543      ));
544    }
545
546    if total_contribs >= 10000 {
547      lines.push(format!(
548        "{} Contributions   10k+",
549        self.colorize("💎", "magenta")
550      ));
551    } else if total_contribs >= 5000 {
552      lines.push(format!(
553        "{} Contributions   5k+",
554        self.colorize("👑", "yellow")
555      ));
556    } else if total_contribs >= 1000 {
557      lines.push(format!(
558        "{} Contributions   1k+",
559        self.colorize("🎖️", "cyan")
560      ));
561    } else if total_contribs >= 100 {
562      lines.push(format!(
563        "{} Contributions   100+",
564        self.colorize("🏆", "yellow")
565      ));
566    }
567
568    lines
569  }
570
571  fn render_progress_bar(&self, percentage: f64, width: usize) -> String {
572    let width = width.max(1);
573    let capped = percentage.max(0.0).min(100.0);
574    let filled = ((capped / 100.0) * width as f64).round() as usize;
575    let filled = filled.min(width);
576    let empty = width - filled;
577
578    let filled_segment = "▰".repeat(filled);
579    let empty_segment = "▱".repeat(empty);
580
581    let colored_filled = self.colorize(&filled_segment, "green");
582
583    format!("{}{}", colored_filled, empty_segment)
584  }
585
586  fn label(&self, text: &str) -> String {
587    let label = format!("{}:", text);
588    let padded = format!("{:<12}", label);
589    self.colorize(&padded, "bold")
590  }
591
592  fn colorize(&self, text: &str, color: &str) -> String {
593    let color_code = match color {
594      "header" => "\x1b[38;2;118;215;161m",
595      "orange" => "\x1b[38;2;255;184;108m",
596      "green" => "\x1b[38;2;80;250;123m",
597      "muted" => "\x1b[38;2;68;71;90m",
598      "bold" => "\x1b[1m",
599      "red" => "\x1b[91m",
600      "yellow" => "\x1b[93m",
601      "cyan" => "\x1b[96m",
602      "magenta" => "\x1b[95m",
603      _ => "\x1b[0m",
604    };
605
606    format!("{}{}\x1b[0m", color_code, text)
607  }
608
609  fn display_width(&self, text: &str) -> usize {
610    // Calculate actual display width after removing ANSI codes
611    let ansi_pattern = regex::Regex::new(r"\x1b\[[0-9;]*m").unwrap();
612    let clean = ansi_pattern.replace_all(text, "");
613    clean.chars().count()
614  }
615
616  fn format_pull_requests(&self, stats: &Value) -> Vec<String> {
617    let mut lines = Vec::new();
618
619    let prs = match stats.get("pull_requests") {
620      Some(pr_data) if pr_data.is_object() => pr_data,
621      _ => return lines,
622    };
623
624    let open = prs["open"].as_i64().unwrap_or(0);
625    let awaiting = prs["awaiting_review"].as_i64().unwrap_or(0);
626    let mentions = prs["mentions"].as_i64().unwrap_or(0);
627
628    lines.push(self.colorize("PULL REQUESTS", "header"));
629    lines.push(self.colorize(&"─".repeat(13), "muted")); // Add underline
630
631    lines.push(format!(
632      "{} {}",
633      self.colorize("Awaiting Review:", "header"),
634      awaiting
635    ));
636    lines.push(format!("  {}", self.colorize("• None", "muted")));
637
638    lines.push(format!(
639      "{} {}",
640      self.colorize("Your Open PRs:", "header"),
641      open
642    ));
643    lines.push(format!("  {}", self.colorize("• None", "muted")));
644
645    lines.push(format!(
646      "{} {}",
647      self.colorize("Mentions:", "header"),
648      mentions
649    ));
650    lines.push(format!("  {}", self.colorize("• None", "muted")));
651
652    lines
653  }
654
655  fn format_issues(&self, stats: &Value) -> Vec<String> {
656    let mut lines = Vec::new();
657
658    let issues = match stats.get("issues") {
659      Some(issue_data) if issue_data.is_object() => issue_data,
660      _ => return lines,
661    };
662
663    let assigned = issues["assigned"].as_i64().unwrap_or(0);
664    let created = issues["created"].as_i64().unwrap_or(0);
665    let mentions = issues["mentions"].as_i64().unwrap_or(0);
666
667    lines.push(self.colorize("ISSUES", "header"));
668    lines.push(self.colorize(&"─".repeat(6), "muted")); // Add underline
669
670    lines.push(format!(
671      "{} {}",
672      self.colorize("Assigned:", "header"),
673      assigned
674    ));
675    lines.push(format!("  {}", self.colorize("• None", "muted")));
676
677    lines.push(format!(
678      "{} {}",
679      self.colorize("Created (open):", "header"),
680      created
681    ));
682    lines.push(format!("  {}", self.colorize("• None", "muted")));
683
684    lines.push(format!(
685      "{} {}",
686      self.colorize("Mentions:", "header"),
687      mentions
688    ));
689    lines.push(format!("  {}", self.colorize("• None", "muted")));
690
691    lines
692  }
693}