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