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 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 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 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 (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; }
140 if !self.visual_opts.no_achievements {
141 right_lines += 5; }
143
144 let max_lines = graph_height.max(right_lines);
145 let right_width = 40; (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; if !self.visual_opts.no_pr || !self.visual_opts.no_issues {
167 total += 6; }
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; }
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); }
182 }
183 if !self.visual_opts.no_achievements {
184 right_height += 5; }
186
187 let max_height = graph_height.max(right_height);
188 let right_width = 45; (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 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 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 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 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 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 if !pr_lines.is_empty() || !issue_lines.is_empty() {
291 if !graph_lines.is_empty() {
292 graph_lines.push(String::new()); }
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 graph_lines.push(format!(" {}{}{}", pr_part, padding, issue_part));
315 }
316 }
317 }
318
319 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 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; let spaced = self.visual_opts.spaced;
381
382 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; let spaced = self.visual_opts.spaced;
408
409 let lines = graph.render(
410 None, None, 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 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 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")); 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")); 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}