1use super::graph::ContributionGraph;
2use crate::config::Config;
3use anyhow::Result;
4use serde_json::Value;
5
6#[derive(Debug)]
7enum Layout {
8 Minimal,
9 Compact,
10 Full,
11}
12
13#[derive(Debug, Clone, Default)]
14pub struct VisualOptions {
15 pub graph_only: bool,
16 pub spaced: bool,
17 pub graph_timeline: bool,
18 pub width: Option<usize>,
19 pub height: Option<usize>,
20 pub no_achievements: bool,
21 pub no_languages: bool,
22 pub no_issues: bool,
23 pub no_pr: bool,
24 pub no_account: bool,
25 pub no_grid: bool,
26}
27
28pub struct DisplayFormatter {
29 config: Config,
30 terminal_width: usize,
31 terminal_height: usize,
32 visual_opts: VisualOptions,
33}
34
35impl DisplayFormatter {
36 pub fn new(config: Config, visual_opts: VisualOptions) -> Result<Self> {
37 let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
38
39 Ok(Self {
40 config,
41 terminal_width: cols as usize,
42 terminal_height: rows as usize,
43 visual_opts,
44 })
45 }
46
47 pub fn display(&self, username: &str, user_data: &Value, stats: &Value) -> Result<()> {
48 if self.visual_opts.graph_timeline {
50 let timeline = crate::utils::timeline::get_git_timeline_graph(false)?;
51 println!();
52 println!("{}", timeline);
53 return Ok(());
54 }
55
56 if self.visual_opts.graph_only {
58 self.display_contribution_graph(username, stats)?;
59 println!();
60 return Ok(());
61 }
62
63 let layout = self.determine_layout(username, user_data, stats);
64
65 match layout {
66 Layout::Minimal => self.display_minimal(username, stats)?,
67 Layout::Compact => self.display_compact(username, user_data, stats)?,
68 Layout::Full => self.display_full(username, user_data, stats)?,
69 }
70
71 println!();
72 Ok(())
73 }
74
75 fn determine_layout(&self, username: &str, user_data: &Value, stats: &Value) -> Layout {
76 let layouts = vec![Layout::Full, Layout::Compact, Layout::Minimal];
79
80 let available_height = self.terminal_height.saturating_sub(2).max(10);
81
82 for layout in layouts {
83 let (width, height) = self.calculate_layout_dimensions(username, user_data, stats, &layout);
84
85 if width <= self.terminal_width && height <= available_height {
86 return layout;
87 }
88 }
89
90 Layout::Minimal
91 }
92
93 fn calculate_layout_dimensions(
94 &self,
95 username: &str,
96 user_data: &Value,
97 stats: &Value,
98 layout: &Layout,
99 ) -> (usize, usize) {
100 match layout {
101 Layout::Minimal => self.calculate_minimal_dimensions(username, stats),
102 Layout::Compact => self.calculate_compact_dimensions(username, user_data, stats),
103 Layout::Full => self.calculate_full_dimensions(username, user_data, stats),
104 }
105 }
106
107 fn calculate_minimal_dimensions(&self, _username: &str, _stats: &Value) -> (usize, usize) {
108 if !self.visual_opts.no_grid {
109 let width = self
110 .visual_opts
111 .width
112 .unwrap_or(self.terminal_width.saturating_sub(4));
113 let height = self.visual_opts.height.unwrap_or(7);
114 (width, height)
115 } else {
116 (50, 2)
118 }
119 }
120
121 fn calculate_compact_dimensions(
122 &self,
123 _username: &str,
124 user_data: &Value,
125 stats: &Value,
126 ) -> (usize, usize) {
127 let graph_width = self
128 .visual_opts
129 .width
130 .unwrap_or_else(|| (self.terminal_width.saturating_sub(40).max(40) * 3) / 4);
131
132 let graph_height = if !self.visual_opts.no_grid {
133 self.visual_opts.height.unwrap_or(7) + 1
135 } else {
136 let graph = ContributionGraph::from_json(&stats["contribution_graph"]);
138 let total_contribs = graph.calculate_total_contributions();
139 let name = user_data["name"].as_str().unwrap_or("unknown");
140 let header_text = format!("{} - {} contributions this year", name, total_contribs);
141 return (self.display_width(&header_text), 1);
142 };
143
144 let mut right_lines = Vec::new();
146
147 if !self.visual_opts.no_account {
148 let graph = ContributionGraph::from_json(&stats["contribution_graph"]);
149 let total_contribs = graph.calculate_total_contributions();
150 let name = user_data["name"].as_str().unwrap_or("unknown");
151 let info_text = format!("{} - {} contributions this year", name, total_contribs);
152 right_lines.push(info_text);
153 }
154
155 if !self.visual_opts.no_achievements {
156 let graph = ContributionGraph::from_json(&stats["contribution_graph"]);
157 let (current_streak, max_streak) = graph.calculate_streaks();
158 let total_contribs = graph.calculate_total_contributions();
159
160 if !right_lines.is_empty() {
161 right_lines.push(String::new());
162 }
163
164 right_lines.push("ACHIEVEMENTS".to_string());
165 right_lines.push("────────────".to_string());
166
167 if current_streak > 0 {
168 right_lines.push(format!("🔥 Current Streak {} days", current_streak));
169 }
170 if max_streak > 0 {
171 right_lines.push(format!("⭐ Best Streak {} days", max_streak));
172 }
173 if total_contribs >= 100 {
174 right_lines.push("🏆 Contributions 100+".to_string());
175 }
176 }
177
178 let max_lines = graph_height.max(right_lines.len());
179 let right_width = right_lines
180 .iter()
181 .map(|line| self.display_width(line))
182 .max()
183 .unwrap_or(0);
184
185 (graph_width + 2 + right_width, max_lines + 2)
187 }
188
189 fn calculate_full_dimensions(
190 &self,
191 _username: &str,
192 user_data: &Value,
193 stats: &Value,
194 ) -> (usize, usize) {
195 let graph_width = self
196 .visual_opts
197 .width
198 .unwrap_or_else(|| ((self.terminal_width.saturating_sub(10).max(50) * 3) / 4).max(50));
199
200 let left_height = if !self.visual_opts.no_grid {
202 let base_height = self.visual_opts.height.unwrap_or(7);
203 let mut total = base_height + 1; if !self.visual_opts.no_pr || !self.visual_opts.no_issues {
207 total += 1; let pr_lines = if !self.visual_opts.no_pr {
210 self.format_pull_requests(stats).len()
211 } else {
212 0
213 };
214 let issue_lines = if !self.visual_opts.no_issues {
215 self.format_issues(stats).len()
216 } else {
217 0
218 };
219
220 total += pr_lines.max(issue_lines);
222 }
223 total
224 } else {
225 0
226 };
227
228 let mut right_lines = Vec::new();
230
231 if !self.visual_opts.no_account {
232 let graph = ContributionGraph::from_json(&stats["contribution_graph"]);
233 let total_contribs = graph.calculate_total_contributions();
234
235 let user_info_lines = self.format_user_info("", user_data, stats, total_contribs);
237 right_lines.extend(user_info_lines);
238 }
239
240 if !self.visual_opts.no_languages && self.terminal_width >= 120 {
242 let language_lines = self.format_languages(stats);
243 if !language_lines.is_empty() {
244 if !right_lines.is_empty() {
245 right_lines.push(String::new());
246 }
247 right_lines.extend(language_lines);
248 }
249 }
250
251 if !self.visual_opts.no_achievements {
252 let graph = ContributionGraph::from_json(&stats["contribution_graph"]);
253 let achievement_lines = self.format_achievements(&graph);
254 if !achievement_lines.is_empty() {
255 if !right_lines.is_empty() {
256 right_lines.push(String::new());
257 }
258 right_lines.extend(achievement_lines);
259 }
260 }
261
262 let max_height = left_height.max(right_lines.len());
263
264 let left_width = if !self.visual_opts.no_grid {
267 let graph_lines = self
269 .get_contribution_graph_lines_with_width(_username, stats, graph_width)
270 .unwrap_or_default();
271
272 let mut all_left_lines = graph_lines;
274
275 if !self.visual_opts.no_pr || !self.visual_opts.no_issues {
276 let pr_lines = if !self.visual_opts.no_pr {
277 self.format_pull_requests(stats)
278 } else {
279 vec![]
280 };
281 let issue_lines = if !self.visual_opts.no_issues {
282 self.format_issues(stats)
283 } else {
284 vec![]
285 };
286
287 if !pr_lines.is_empty() || !issue_lines.is_empty() {
288 all_left_lines.push(String::new());
289
290 let pr_width = pr_lines
291 .iter()
292 .map(|l| self.display_width(l))
293 .max()
294 .unwrap_or(0);
295 let max_section_lines = pr_lines.len().max(issue_lines.len());
296
297 for i in 0..max_section_lines {
298 let pr_part = if i < pr_lines.len() { &pr_lines[i] } else { "" };
299 let issue_part = if i < issue_lines.len() {
300 &issue_lines[i]
301 } else {
302 ""
303 };
304 let pr_part_width = self.display_width(pr_part);
305 let padding_len = pr_width.saturating_sub(pr_part_width) + 3;
306
307 let line = format!(" {}{}{}", pr_part, " ".repeat(padding_len), issue_part);
309 all_left_lines.push(line);
310 }
311 }
312 }
313
314 all_left_lines
316 .iter()
317 .map(|l| self.display_width(l))
318 .max()
319 .unwrap_or(0)
320 } else {
321 0
322 };
323
324 let right_width = right_lines
325 .iter()
326 .map(|line| self.display_width(line))
327 .max()
328 .unwrap_or(0);
329
330 (left_width + 2 + right_width, max_height + 2)
332 }
333 fn display_minimal(&self, username: &str, stats: &Value) -> Result<()> {
334 println!();
335 self.display_contribution_graph(username, stats)?;
336 Ok(())
337 }
338
339 fn display_compact(&self, username: &str, user_data: &Value, stats: &Value) -> Result<()> {
340 println!();
341
342 let graph = ContributionGraph::from_json(&stats["contribution_graph"]);
343 let graph_width = (self.terminal_width.saturating_sub(40).max(40) * 3) / 4;
344
345 let graph_lines = if !self.visual_opts.no_grid {
347 self.get_contribution_graph_lines_with_width(username, stats, graph_width)?
348 } else {
349 let total_contribs = graph.calculate_total_contributions();
350 let name = user_data["name"].as_str().unwrap_or(username);
351 vec![format!(
352 "\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",
353 name, total_contribs
354 )]
355 };
356
357 let mut right_lines = Vec::new();
359
360 if !self.visual_opts.no_account {
361 let total_contribs = graph.calculate_total_contributions();
362 let name = user_data["name"].as_str().unwrap_or(username);
363 right_lines.push(format!(
364 "\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",
365 name, total_contribs
366 ));
367 }
368
369 if !self.visual_opts.no_achievements {
370 let achievement_lines = self.format_achievements(&graph);
371 if !achievement_lines.is_empty() {
372 if !right_lines.is_empty() {
374 right_lines.push(String::new());
375 }
376 right_lines.extend(achievement_lines);
377 }
378 }
379
380 let max_lines = graph_lines.len().max(right_lines.len());
382 for i in 0..max_lines {
383 let graph_part = if i < graph_lines.len() {
384 &graph_lines[i]
385 } else {
386 ""
387 };
388 let graph_len = self.display_width(graph_part);
389 let padding = " ".repeat(graph_width.saturating_sub(graph_len));
390
391 let info_part = if i < right_lines.len() {
392 &right_lines[i]
393 } else {
394 ""
395 };
396
397 println!("{}{} {}", graph_part, padding, info_part);
398 }
399
400 Ok(())
401 }
402
403 fn combine_section_grid(&self, columns: &[Vec<String>], width_limit: usize) -> Vec<String> {
404 let active_columns: Vec<&Vec<String>> = columns.iter().filter(|col| !col.is_empty()).collect();
405
406 if active_columns.is_empty() {
407 return vec![];
408 }
409
410 let indent = " ";
411 let gap = " ";
412 let gap_width = gap.len();
413 let indent_width = indent.len();
414
415 let column_info: Vec<(&Vec<String>, usize)> = active_columns
417 .iter()
418 .map(|col| {
419 let max_width = col
420 .iter()
421 .map(|line| self.display_width(line))
422 .max()
423 .unwrap_or(0);
424 (*col, max_width)
425 })
426 .collect();
427
428 let mut rows: Vec<Vec<(&Vec<String>, usize)>> = vec![];
430 let mut current_row: Vec<(&Vec<String>, usize)> = vec![];
431 let mut current_width = indent_width;
432
433 for (col, width) in column_info {
434 let projected = if current_row.is_empty() {
435 width
436 } else {
437 width + gap_width
438 };
439
440 if !current_row.is_empty() && current_width + projected > width_limit {
441 rows.push(current_row);
442 current_row = vec![];
443 current_width = indent_width;
444 }
445
446 if current_row.is_empty() {
447 current_width += width;
448 } else {
449 current_width += gap_width + width;
450 }
451
452 current_row.push((col, width));
453 }
454
455 if !current_row.is_empty() {
456 rows.push(current_row);
457 }
458
459 let mut combined = vec![];
461 for (row_idx, row) in rows.iter().enumerate() {
462 let max_lines = row.iter().map(|(col, _)| col.len()).max().unwrap_or(0);
463
464 for line_idx in 0..max_lines {
465 let mut parts = vec![];
466 for (col_idx, (col, width)) in row.iter().enumerate() {
467 let text = if line_idx < col.len() {
468 &col[line_idx]
469 } else {
470 ""
471 };
472 let text_width = self.display_width(text);
473 let pad_width = width.saturating_sub(text_width);
474 let pad = " ".repeat(pad_width);
475 let spacer = if col_idx < row.len() - 1 { gap } else { "" };
476 parts.push(format!("{}{}{}", text, pad, spacer));
477 }
478
479 combined.push(format!("{}{}", indent, parts.join("").trim_end()));
480 }
481
482 if row_idx < rows.len() - 1 {
483 combined.push(String::new());
484 }
485 }
486
487 combined
488 }
489
490 fn display_full(&self, username: &str, user_data: &Value, stats: &Value) -> Result<()> {
491 println!();
492
493 let graph = ContributionGraph::from_json(&stats["contribution_graph"]);
494 let total_contribs = graph.calculate_total_contributions();
495
496 let graph_width = self
498 .visual_opts
499 .width
500 .unwrap_or_else(|| ((self.terminal_width.saturating_sub(10).max(50) * 3) / 4).max(50));
501
502 let mut graph_lines = if !self.visual_opts.no_grid {
504 self.get_contribution_graph_lines_with_width(username, stats, graph_width)?
505 } else {
506 vec![]
507 };
508
509 if !self.visual_opts.no_pr || !self.visual_opts.no_issues {
511 let pr_lines = if !self.visual_opts.no_pr {
512 self.format_pull_requests(stats)
513 } else {
514 vec![]
515 };
516
517 let issue_lines = if !self.visual_opts.no_issues {
518 self.format_issues(stats)
519 } else {
520 vec![]
521 };
522
523 let section_columns: Vec<Vec<String>> = if !pr_lines.is_empty() && !issue_lines.is_empty() {
525 let pr_width = pr_lines
526 .iter()
527 .map(|l| self.display_width(l))
528 .max()
529 .unwrap_or(0);
530 let issue_width = issue_lines
531 .iter()
532 .map(|l| self.display_width(l))
533 .max()
534 .unwrap_or(0);
535 let total_width = pr_width + issue_width + 3; if total_width <= graph_width {
538 vec![pr_lines, issue_lines]
539 } else {
540 vec![]
542 }
543 } else if !pr_lines.is_empty() {
544 vec![pr_lines]
545 } else if !issue_lines.is_empty() {
546 vec![issue_lines]
547 } else {
548 vec![]
549 };
550
551 if !section_columns.is_empty() {
553 if !graph_lines.is_empty() {
554 graph_lines.push(String::new()); }
556
557 let combined = self.combine_section_grid(§ion_columns, graph_width);
558 graph_lines.extend(combined);
559 }
560 }
561
562 let mut right_lines = vec![];
564
565 if !self.visual_opts.no_account {
566 right_lines.extend(self.format_user_info(username, user_data, stats, total_contribs));
567 }
568
569 if !self.visual_opts.no_languages && self.terminal_width >= 120 {
571 let language_lines = self.format_languages(stats);
572 if !language_lines.is_empty() {
573 if !right_lines.is_empty() {
574 right_lines.push(String::new());
575 }
576 right_lines.extend(language_lines);
577 }
578 }
579
580 if !self.visual_opts.no_achievements {
581 let achievement_lines = self.format_achievements(&graph);
582 if !achievement_lines.is_empty() {
583 if !right_lines.is_empty() {
584 right_lines.push(String::new());
585 }
586 right_lines.extend(achievement_lines);
587 }
588 }
589
590 let max_left_width = graph_lines
592 .iter()
593 .map(|l| self.display_width(l))
594 .max()
595 .unwrap_or(0);
596
597 let max_lines = graph_lines.len().max(right_lines.len());
598 for i in 0..max_lines {
599 let left = if i < graph_lines.len() {
600 &graph_lines[i]
601 } else {
602 ""
603 };
604 let left_width = self.display_width(left);
605 let padding = " ".repeat(max_left_width.saturating_sub(left_width));
606
607 let right = if i < right_lines.len() {
608 &right_lines[i]
609 } else {
610 ""
611 };
612
613 println!("{}{} {}", left, padding, right);
614 }
615
616 Ok(())
617 }
618
619 fn get_contribution_graph_lines(&self, _username: &str, stats: &Value) -> Result<Vec<String>> {
620 let graph = ContributionGraph::from_json(&stats["contribution_graph"]);
621 let custom_box = self.config.custom_box.as_deref().unwrap_or("■");
622 let show_date = true;
624 let spaced = self.visual_opts.spaced;
625
626 let lines = graph.render(
628 self.visual_opts.width,
629 self.visual_opts.height,
630 custom_box,
631 &self.config.colors,
632 show_date,
633 spaced,
634 );
635
636 Ok(lines)
637 }
638
639 fn get_contribution_graph_lines_with_width(
640 &self,
641 _username: &str,
642 stats: &Value,
643 width_constraint: usize,
644 ) -> Result<Vec<String>> {
645 let graph = ContributionGraph::from_json(&stats["contribution_graph"]);
646 let custom_box = self.config.custom_box.as_deref().unwrap_or("■");
647 let show_date = true;
648 let spaced = self.visual_opts.spaced;
649
650 let block_width = 2;
653 let header_margin = 4;
654 let available_for_graph = width_constraint.saturating_sub(header_margin);
655 let max_weeks = (available_for_graph / block_width).max(13).min(52);
656
657 let lines = graph.render(
659 Some(max_weeks),
660 self.visual_opts.height,
661 custom_box,
662 &self.config.colors,
663 show_date,
664 spaced,
665 );
666
667 Ok(lines)
668 }
669
670 fn display_contribution_graph(&self, username: &str, stats: &Value) -> Result<()> {
671 let lines = self.get_contribution_graph_lines(username, stats)?;
672 for line in lines {
673 println!("{}", line);
674 }
675 Ok(())
676 }
677
678 pub fn display_simulation_from_grid(&self, grid: Vec<Vec<u8>>) -> Result<()> {
679 let graph = ContributionGraph::from_grid(grid);
680 let custom_box = self.config.custom_box.as_deref().unwrap_or("■");
681 let show_date = false; let spaced = self.visual_opts.spaced;
683
684 let lines = graph.render(
685 None, None, custom_box,
688 &self.config.colors,
689 show_date,
690 spaced,
691 );
692
693 for line in lines {
694 println!("{}", line);
695 }
696
697 Ok(())
698 }
699
700 fn format_user_info(
701 &self,
702 _username: &str,
703 user_data: &Value,
704 stats: &Value,
705 total_contribs: u32,
706 ) -> Vec<String> {
707 let mut lines = Vec::new();
708
709 let name = user_data["name"].as_str().unwrap_or("Unknown");
710 let header = format!(
711 "\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",
712 name, total_contribs
713 );
714 lines.push(header);
715
716 let plain = format!("{} - {} contributions this year", name, total_contribs);
717 lines.push(self.colorize(&"─".repeat(plain.len()), "muted"));
718
719 if let Some(bio) = user_data["bio"].as_str() {
720 if !bio.is_empty() {
721 let trimmed = bio.replace('\n', " ");
722 let truncated = if trimmed.len() > 80 {
723 &trimmed[..80]
724 } else {
725 &trimmed
726 };
727 lines.push(format!("{} {}", self.label("Bio"), truncated));
728 }
729 }
730
731 if let Some(company) = user_data["company"].as_str() {
732 if !company.is_empty() {
733 lines.push(format!("{} {}", self.label("Company"), company));
734 }
735 }
736
737 if let Some(blog) = user_data["blog"].as_str() {
738 if !blog.is_empty() {
739 lines.push(format!("{} {}", self.label("Website"), blog));
740 }
741 }
742
743 if let Some(total_stars) = stats["total_stars"].as_i64() {
745 lines.push(format!("{} {} ⭐", self.label("Stars"), total_stars));
746 }
747
748 lines
749 }
750
751 fn format_languages(&self, stats: &Value) -> Vec<String> {
752 let mut lines = Vec::new();
753
754 let languages = match stats["languages"].as_object() {
755 Some(langs) if !langs.is_empty() => langs,
756 _ => return lines,
757 };
758
759 lines.push(self.colorize("TOP LANGUAGES", "header"));
760 lines.push(self.colorize(&"─".repeat(13), "muted"));
761
762 let mut lang_vec: Vec<_> = languages.iter().collect();
763 lang_vec.sort_by(|a, b| {
764 let a_val = a.1.as_f64().unwrap_or(0.0);
765 let b_val = b.1.as_f64().unwrap_or(0.0);
766 b_val.partial_cmp(&a_val).unwrap()
767 });
768
769 for (lang, percentage) in lang_vec.iter().take(5) {
770 let pct = percentage.as_f64().unwrap_or(0.0);
771 let lang_name = if lang.to_lowercase() == "jupyter notebook" {
772 "Jupyter"
773 } else {
774 lang
775 };
776
777 let bar = self.render_progress_bar(pct, 24);
778 lines.push(format!("{} {} {:5.1}%", self.label(lang_name), bar, pct));
779 }
780
781 lines
782 }
783
784 fn format_achievements(&self, graph: &ContributionGraph) -> Vec<String> {
785 let mut lines = Vec::new();
786
787 let (current_streak, max_streak) = graph.calculate_streaks();
788 let total_contribs = graph.calculate_total_contributions();
789
790 let mut entries = Vec::new();
792
793 if current_streak > 0 {
794 let streak_text = if current_streak == 1 {
795 format!("{} day", current_streak)
796 } else {
797 format!("{} days", current_streak)
798 };
799 entries.push((
800 format!("{} Current Streak", self.colorize("🔥", "red")),
801 streak_text,
802 ));
803 }
804
805 if max_streak > 0 {
806 let streak_text = if max_streak == 1 {
807 format!("{} day", max_streak)
808 } else {
809 format!("{} days", max_streak)
810 };
811 entries.push((
812 format!("{} Best Streak", self.colorize("⭐", "yellow")),
813 streak_text,
814 ));
815 }
816
817 if total_contribs >= 10000 {
818 entries.push((
819 format!("{} Contributions", self.colorize("💎", "magenta")),
820 "10k+".to_string(),
821 ));
822 } else if total_contribs >= 5000 {
823 entries.push((
824 format!("{} Contributions", self.colorize("👑", "yellow")),
825 "5k+".to_string(),
826 ));
827 } else if total_contribs >= 1000 {
828 entries.push((
829 format!("{} Contributions", self.colorize("🎖️", "cyan")),
830 "1k+".to_string(),
831 ));
832 } else if total_contribs >= 100 {
833 entries.push((
834 format!("{} Contributions", self.colorize("🏆", "yellow")),
835 "100+".to_string(),
836 ));
837 }
838
839 if !entries.is_empty() {
840 let title = "ACHIEVEMENTS";
841 lines.push(self.colorize(title, "header"));
842 lines.push(self.colorize(&"─".repeat(title.len()), "muted"));
843
844 let label_width = entries
846 .iter()
847 .map(|(label, _)| self.display_width(label))
848 .max()
849 .unwrap_or(0);
850
851 for (label, value) in entries {
852 let label_len = self.display_width(&label);
853 let padding = " ".repeat(label_width.saturating_sub(label_len));
854 lines.push(format!("{}{} {}", label, padding, value));
855 }
856 }
857
858 lines
859 }
860
861 fn render_progress_bar(&self, percentage: f64, width: usize) -> String {
862 let width = width.max(1);
863 let capped = percentage.max(0.0).min(100.0);
864 let filled = ((capped / 100.0) * width as f64).round() as usize;
865 let filled = filled.min(width);
866 let empty = width - filled;
867
868 let filled_segment = "▰".repeat(filled);
869 let empty_segment = "▱".repeat(empty);
870
871 let colored_filled = self.colorize(&filled_segment, "green");
872
873 format!("{}{}", colored_filled, empty_segment)
874 }
875
876 fn label(&self, text: &str) -> String {
877 let label = format!("{}:", text);
878 let padded = format!("{:<12}", label);
879 self.colorize(&padded, "bold")
880 }
881
882 fn colorize(&self, text: &str, color: &str) -> String {
883 let color_code = match color {
884 "header" => "\x1b[38;2;118;215;161m",
885 "orange" => "\x1b[38;2;255;184;108m",
886 "green" => "\x1b[38;2;80;250;123m",
887 "muted" => "\x1b[38;2;68;71;90m",
888 "bold" => "\x1b[1m",
889 "red" => "\x1b[91m",
890 "yellow" => "\x1b[93m",
891 "cyan" => "\x1b[96m",
892 "magenta" => "\x1b[95m",
893 _ => "\x1b[0m",
894 };
895
896 format!("{}{}\x1b[0m", color_code, text)
897 }
898
899 fn display_width(&self, text: &str) -> usize {
900 let ansi_pattern = regex::Regex::new(r"\x1b\[[0-9;]*m").unwrap();
902 let clean = ansi_pattern.replace_all(text, "");
903
904 use unicode_width::UnicodeWidthStr;
906 clean.width()
907 }
908
909 fn truncate_text(&self, text: &str, max_width: usize) -> String {
910 if self.display_width(text) <= max_width {
911 return text.to_string();
912 }
913
914 let ellipsis = '…';
915 let mut truncated = String::new();
916 for ch in text.chars() {
917 let test = format!("{}{}{}", truncated, ch, ellipsis);
918 if self.display_width(&test) > max_width {
919 break;
920 }
921 truncated.push(ch);
922 }
923 format!("{}{}", truncated, ellipsis)
924 }
925
926 fn format_pull_requests(&self, stats: &Value) -> Vec<String> {
927 let mut lines = Vec::new();
928
929 let prs = match stats.get("pull_requests") {
930 Some(pr_data) if pr_data.is_object() => pr_data,
931 _ => return lines,
932 };
933
934 lines.push(self.colorize("PULL REQUESTS", "header"));
935 lines.push(self.colorize(&"─".repeat(13), "muted"));
936
937 let labels = ["Awaiting Review", "Your Open PRs", "Mentions"];
939 let label_width = labels.iter().map(|s| s.len()).max().unwrap_or(0) + 2;
940
941 for (label, key) in [
942 ("Awaiting Review", "awaiting_review"),
943 ("Your Open PRs", "open"),
944 ("Mentions", "mentions"),
945 ] {
946 let data = prs.get(key);
947 let total = data
948 .and_then(|d| d.get("total_count"))
949 .and_then(|t| t.as_i64())
950 .unwrap_or(0);
951
952 let label_text = format!("{}:", label);
953 let padded_label = format!("{:<width$}", label_text, width = label_width);
954 lines.push(format!(
955 "{} {}",
956 self.colorize(&padded_label, "header"),
957 total
958 ));
959
960 let items = data
962 .and_then(|d| d.get("items"))
963 .and_then(|i| i.as_array())
964 .map(|arr| &arr[..arr.len().min(3)])
965 .unwrap_or(&[]);
966
967 if items.is_empty() {
968 lines.push(format!(" {}", self.colorize("• None", "muted")));
969 } else {
970 for item in items {
971 let title = item.get("title").and_then(|t| t.as_str()).unwrap_or("");
972 let repo = item.get("repo").and_then(|r| r.as_str()).unwrap_or("");
973
974 let mut bullet = format!("• {}", self.truncate_text(title, 24));
975 if !repo.is_empty() {
976 bullet.push_str(&format!(" ({})", self.truncate_text(repo, 16)));
977 }
978 lines.push(format!(" {}", bullet));
979 }
980 }
981 }
982
983 lines
984 }
985
986 fn format_issues(&self, stats: &Value) -> Vec<String> {
987 let mut lines = Vec::new();
988
989 let issues = match stats.get("issues") {
990 Some(issue_data) if issue_data.is_object() => issue_data,
991 _ => return lines,
992 };
993
994 lines.push(self.colorize("ISSUES", "header"));
995 lines.push(self.colorize(&"─".repeat(6), "muted"));
996
997 let labels = ["Assigned", "Created (open)", "Mentions"];
999 let label_width = labels.iter().map(|s| s.len()).max().unwrap_or(0) + 2;
1000
1001 for (label, key) in [
1002 ("Assigned", "assigned"),
1003 ("Created (open)", "created"),
1004 ("Mentions", "mentions"),
1005 ] {
1006 let data = issues.get(key);
1007 let total = data
1008 .and_then(|d| d.get("total_count"))
1009 .and_then(|t| t.as_i64())
1010 .unwrap_or(0);
1011
1012 let label_text = format!("{}:", label);
1013 let padded_label = format!("{:<width$}", label_text, width = label_width);
1014 lines.push(format!(
1015 "{} {}",
1016 self.colorize(&padded_label, "header"),
1017 total
1018 ));
1019
1020 let items = data
1022 .and_then(|d| d.get("items"))
1023 .and_then(|i| i.as_array())
1024 .map(|arr| &arr[..arr.len().min(3)])
1025 .unwrap_or(&[]);
1026
1027 if items.is_empty() {
1028 lines.push(format!(" {}", self.colorize("• None", "muted")));
1029 } else {
1030 for item in items {
1031 let title = item.get("title").and_then(|t| t.as_str()).unwrap_or("");
1032 let repo = item.get("repo").and_then(|r| r.as_str()).unwrap_or("");
1033
1034 let mut bullet = format!("• {}", self.truncate_text(title, 24));
1035 if !repo.is_empty() {
1036 bullet.push_str(&format!(" ({})", self.truncate_text(repo, 16)));
1037 }
1038 lines.push(format!(" {}", bullet));
1039 }
1040 }
1041 }
1042
1043 lines
1044 }
1045}